diff --git a/.changeset/all-crabs-smell.md b/.changeset/all-crabs-smell.md new file mode 100644 index 0000000000..930134bda1 --- /dev/null +++ b/.changeset/all-crabs-smell.md @@ -0,0 +1,5 @@ +--- +'@lynx-js/testing-environment': patch +--- + +Add `__RemoveGestureDetector` PAPI binding diff --git a/.changeset/easy-waves-pump.md b/.changeset/easy-waves-pump.md new file mode 100644 index 0000000000..6b6239c210 --- /dev/null +++ b/.changeset/easy-waves-pump.md @@ -0,0 +1,6 @@ +--- +"@lynx-js/react-webpack-plugin": patch +"@lynx-js/react-rsbuild-plugin": patch +--- + +Support rstest for testing library using a dedicated testing loader. diff --git a/.changeset/fair-horses-worry.md b/.changeset/fair-horses-worry.md new file mode 100644 index 0000000000..15bd66cb5e --- /dev/null +++ b/.changeset/fair-horses-worry.md @@ -0,0 +1,5 @@ +--- +'@lynx-js/react': patch +--- + +Remove stale gestures when gestures are removed diff --git a/.changeset/fair-plums-share.md b/.changeset/fair-plums-share.md new file mode 100644 index 0000000000..a6a64189b7 --- /dev/null +++ b/.changeset/fair-plums-share.md @@ -0,0 +1,11 @@ +--- +"@lynx-js/testing-environment": minor +--- + +**BREAKING CHANGE**: + +Align the public test-environment API around `LynxEnv`. + +`LynxTestingEnv` now expects a `{ window }`-shaped environment instead of relying on a concrete `JSDOM` instance or `global.jsdom`. Callers that construct `LynxTestingEnv` manually or initialize the environment through globals should migrate to `new LynxTestingEnv({ window })` or set `global.lynxEnv`. + +This release also adds the `@lynx-js/testing-environment/env/rstest` entry for running the shared testing-environment suite under rstest. diff --git a/.changeset/metal-carrots-itch.md b/.changeset/metal-carrots-itch.md new file mode 100644 index 0000000000..1a7036ea28 --- /dev/null +++ b/.changeset/metal-carrots-itch.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/react": patch +--- + +refactor: set state of suspense to render fallback diff --git a/.changeset/mighty-buckets-train.md b/.changeset/mighty-buckets-train.md new file mode 100644 index 0000000000..853d812bb3 --- /dev/null +++ b/.changeset/mighty-buckets-train.md @@ -0,0 +1,3 @@ +--- + +--- diff --git a/.changeset/ninety-pants-tease.md b/.changeset/ninety-pants-tease.md new file mode 100644 index 0000000000..644f29a76b --- /dev/null +++ b/.changeset/ninety-pants-tease.md @@ -0,0 +1,5 @@ +--- +"create-rspeedy": patch +--- + +Add Rstest ReactLynx Testing Library template. diff --git a/.changeset/red-lamps-arrive.md b/.changeset/red-lamps-arrive.md new file mode 100644 index 0000000000..4ad4b4d80e --- /dev/null +++ b/.changeset/red-lamps-arrive.md @@ -0,0 +1,26 @@ +--- +"@lynx-js/react": patch +--- + +Support rstest for testing library, you can use rstest with RLTL now: + +Create a config file `rstest.config.ts` with the following content: + +```ts +import { defineConfig } from '@rstest/core'; +import { withLynxConfig } from '@lynx-js/react/testing-library/rstest-config'; + +export default defineConfig({ + extends: withLynxConfig(), +}); +``` + +`@lynx-js/react/testing-library/rstest-config` will automatically load your `lynx.config.ts` and apply the same configuration to rstest, so you can keep your test environment consistent with your development environment. + +And then use rstest as usual: + +```bash +$ rstest +``` + +For more usage detail, see https://rstest.rs/ diff --git a/.changeset/refactor-weakref-webcore.md b/.changeset/refactor-weakref-webcore.md new file mode 100644 index 0000000000..3000a3f15b --- /dev/null +++ b/.changeset/refactor-weakref-webcore.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/web-core": patch +--- + +refactor: with WeakRef in element APIs and WASM bindings to improve memory management. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 85af63dbd9..86a67e9b3f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -210,6 +210,15 @@ jobs: pnpm run build --mode development pnpm run lint pnpm run test + cd `mktemp -d` + npx --registry http://localhost:4873 create-rspeedy-canary@latest --template react --dir create-rspeedy-regression-rstest-rltl --tools eslint,rstest-rltl + cd create-rspeedy-regression-rstest-rltl + npx --registry http://localhost:4873 upgrade-rspeedy-canary@latest + pnpm install --registry=http://localhost:4873 + pnpm run build + pnpm run build --mode development + pnpm run lint + pnpm run test test-react: needs: build uses: ./.github/workflows/workflow-test.yml diff --git a/README.md b/README.md index 4625931d00..faec15697e 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Thanks to: - [Preact](https://preactjs.com/) for creating a lightweight and efficient UI library that served as a foundation for the ReactLynx project. - [React](https://react.dev/) for creating a comprehensive and intuitive library for building user interfaces that inspires the programming model of Lynx. - [React Native](https://reactnative.dev/) for groundbreaking work in allowing developers to create truly native apps using JavaScript and React. -- [Rspack](https://rspack.dev/) for providing a fast and flexible build tool that has significantly enhanced the build performance of Lynx projects. +- [Rspack](https://rspack.rs/) for providing a fast and flexible build tool that has significantly enhanced the build performance of Lynx projects. - [React Native for Web](https://necolas.github.io/react-native-web/) project for inspiring the Lynx for Web project, motivating our architectural design with its accessible implementation and interoperability with React DOM. - [SWC](https://github.com/swc-project/swc) project created by [@kdy1](https://github.com/kdy1), which turbocharges ReactLynx's code transformation with Rust-powered efficiency, achieving sub-second build times and frictionless developer experience. diff --git a/examples/gesture/lynx.config.js b/examples/gesture/lynx.config.js new file mode 100644 index 0000000000..62aaf471b2 --- /dev/null +++ b/examples/gesture/lynx.config.js @@ -0,0 +1,26 @@ +import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'; +import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; +import { defineConfig } from '@lynx-js/rspeedy'; + +const enableBundleAnalysis = !!process.env['RSPEEDY_BUNDLE_ANALYSIS']; + +export default defineConfig({ + plugins: [ + pluginReactLynx({ + enableNewGesture: true, + }), + pluginQRCode({ + schema(url) { + return `${url}?fullscreen=true`; + }, + }), + ], + environments: { + web: {}, + lynx: { + performance: { + profile: enableBundleAnalysis, + }, + }, + }, +}); diff --git a/examples/gesture/package.json b/examples/gesture/package.json new file mode 100644 index 0000000000..a4a463829c --- /dev/null +++ b/examples/gesture/package.json @@ -0,0 +1,23 @@ +{ + "name": "@lynx-js/example-gesture", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "rspeedy build", + "dev": "rspeedy dev", + "test:type": "tsc --noEmit" + }, + "dependencies": { + "@lynx-js/gesture-runtime": "workspace:*", + "@lynx-js/react": "workspace:*" + }, + "devDependencies": { + "@lynx-js/preact-devtools": "^5.0.1", + "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", + "@lynx-js/react-rsbuild-plugin": "workspace:*", + "@lynx-js/rspeedy": "workspace:*", + "@lynx-js/types": "3.7.0", + "@types/react": "^18.3.28" + } +} diff --git a/examples/gesture/src/App.css b/examples/gesture/src/App.css new file mode 100644 index 0000000000..0d0f21e767 --- /dev/null +++ b/examples/gesture/src/App.css @@ -0,0 +1,105 @@ +:root { + --bg: #0a1022; + --card: #141e3d; + --card-border: #2a3765; + --text: #eaf0ff; + --text-dim: #9fb0de; + --button: #22315f; + --button-active: #3c5df6; + --box-a: #ff8b3d; + --box-b: #28c6a5; + --box-off: #6f7b9f; +} + +page { + background-color: var(--bg); +} + +.Root { + min-height: 100vh; + padding: 36rpx 24rpx; + box-sizing: border-box; + align-items: center; + justify-content: center; +} + +.Card { + width: 100%; + max-width: 680rpx; + padding: 28rpx; + border-radius: 24rpx; + background: var(--card); + border: 2rpx solid var(--card-border); + gap: 20rpx; +} + +.Title { + color: var(--text); + font-size: 44rpx; + font-weight: 700; +} + +.Description { + color: var(--text-dim); + font-size: 28rpx; + line-height: 38rpx; +} + +.Controls { + flex-direction: column; + gap: 14rpx; +} + +.Button { + border-radius: 16rpx; + background: var(--button); + padding: 16rpx 20rpx; +} + +.Button--active { + background: var(--button-active); +} + +.ButtonText { + color: #ffffff; + font-size: 28rpx; + font-weight: 600; +} + +.ModeText { + color: var(--text); + font-size: 27rpx; +} + +.Stage { + margin-top: 8rpx; + height: 560rpx; + border-radius: 18rpx; + border: 2rpx dashed #4e5e97; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.DragBox { + width: 160rpx; + height: 160rpx; + border-radius: 24rpx; + background: var(--box-a); + align-items: center; + justify-content: center; +} + +.DragBox--diff { + background: var(--box-b); +} + +.DragBox--removed { + background: var(--box-off); +} + +.DragBoxText { + color: #111827; + font-size: 34rpx; + font-weight: 800; +} diff --git a/examples/gesture/src/App.tsx b/examples/gesture/src/App.tsx new file mode 100644 index 0000000000..75a0cc5f6b --- /dev/null +++ b/examples/gesture/src/App.tsx @@ -0,0 +1,171 @@ +import { PanGesture, useGesture } from '@lynx-js/gesture-runtime'; +import { useCallback, useMainThreadRef, useState } from '@lynx-js/react'; + +import './App.css'; + +type GestureMode = 'set' | 'diff' | 'remove'; + +interface Point { + x: number; + y: number; +} + +export function App() { + const [mode, setMode] = useState('set'); + const startPointMTRef = useMainThreadRef({ x: 0, y: 0 }); + const baseOffsetMTRef = useMainThreadRef({ x: 0, y: 0 }); + + const panGestureA = useGesture(PanGesture); + panGestureA + .onBegin((event) => { + 'main thread'; + startPointMTRef.current = { + x: event.params.clientX, + y: event.params.clientY, + }; + event.currentTarget.setStyleProperty('opacity', '0.82'); + console.info('PanGestureA onBegin', startPointMTRef.current); + }) + .onUpdate((event) => { + 'main thread'; + const dx = event.params.clientX - startPointMTRef.current.x; + const dy = event.params.clientY - startPointMTRef.current.y; + const nextX = baseOffsetMTRef.current.x + dx; + const nextY = baseOffsetMTRef.current.y + dy; + event.currentTarget.setStyleProperty( + 'transform', + `translate(${nextX}px, ${nextY}px)`, + ); + console.info('PanGestureA onUpdate', dx, dy, nextX, nextY); + }) + .onEnd((event) => { + 'main thread'; + const dx = event.params.clientX - startPointMTRef.current.x; + const dy = event.params.clientY - startPointMTRef.current.y; + baseOffsetMTRef.current = { + x: baseOffsetMTRef.current.x + dx, + y: baseOffsetMTRef.current.y + dy, + }; + event.currentTarget.setStyleProperty('opacity', '1'); + console.info('PanGestureA onEnd', baseOffsetMTRef.current); + }); + + const panGestureB = useGesture(PanGesture); + panGestureB + .onBegin((event) => { + 'main thread'; + startPointMTRef.current = { + x: event.params.clientX, + y: event.params.clientY, + }; + event.currentTarget.setStyleProperty('opacity', '0.82'); + console.info('PanGestureB onBegin', startPointMTRef.current); + }) + .onUpdate((event) => { + 'main thread'; + + const dx = event.params.clientX - startPointMTRef.current.x; + const nextX = baseOffsetMTRef.current.x + dx; + event.currentTarget.setStyleProperty( + 'transform', + `translate(${nextX}px, 0px)`, + ); + console.info('PanGestureB onUpdate', dx, nextX); + }) + .onEnd((event) => { + 'main thread'; + const dx = event.params.clientX - startPointMTRef.current.x; + baseOffsetMTRef.current = { + x: baseOffsetMTRef.current.x + dx, + y: 0, + }; + event.currentTarget.setStyleProperty('opacity', '1'); + console.info('PanGestureB onEnd', baseOffsetMTRef.current); + }); + + const onSetGesture = useCallback(() => { + 'background-only'; + setMode('set'); + }, []); + + const onDiffGesture = useCallback(() => { + 'background-only'; + setMode('diff'); + }, []); + + const onRemoveGesture = useCallback(() => { + 'background-only'; + setMode('remove'); + }, []); + + const activeGesture = mode === 'set' + ? panGestureA + : (mode === 'diff' + ? panGestureB + : undefined); + + const modeDescription = mode === 'set' + ? 'Mode A: drag in both X and Y.' + : (mode === 'diff' + ? 'Mode B: drag in X only.' + : 'Removed: drag should do nothing.'); + + const dragBoxGestureProps = activeGesture + ? { 'main-thread:gesture': activeGesture } + : {}; + + return ( + + + Gesture Lifecycle Playground + + Tap buttons to trigger set/diff/remove and drag the square. + + + Verify remove semantics: after tapping "Remove Gesture", dragging + should produce no PanGesture logs. + + + Verify update semantics: after tapping "Diff to Gesture B", only + PanGestureB logs should appear. + + + + + Set Gesture A + + + Diff to Gesture B + + + Remove Gesture + + + + {modeDescription} + + + + + {mode === 'set' ? 'A' : (mode === 'diff' ? 'B' : 'OFF')} + + + + + + ); +} diff --git a/examples/gesture/src/index.tsx b/examples/gesture/src/index.tsx new file mode 100644 index 0000000000..019b3f3e74 --- /dev/null +++ b/examples/gesture/src/index.tsx @@ -0,0 +1,13 @@ +import '@lynx-js/preact-devtools'; +import '@lynx-js/react/debug'; +import { root } from '@lynx-js/react'; + +import { App } from './App.jsx'; + +root.render( + , +); + +if (import.meta.webpackHot) { + import.meta.webpackHot.accept(); +} diff --git a/examples/gesture/src/rspeedy-env.d.ts b/examples/gesture/src/rspeedy-env.d.ts new file mode 100644 index 0000000000..1c813a68b0 --- /dev/null +++ b/examples/gesture/src/rspeedy-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/gesture/tsconfig.json b/examples/gesture/tsconfig.json new file mode 100644 index 0000000000..088ad4089c --- /dev/null +++ b/examples/gesture/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@lynx-js/react", + "noEmit": true, + "allowJs": true, + "checkJs": true, + "isolatedDeclarations": false, + }, + "include": ["src", "lynx.config.js"], + "references": [ + { "path": "../../packages/react/tsconfig.json" }, + { "path": "../../packages/rspeedy/core/tsconfig.build.json" }, + { "path": "../../packages/rspeedy/plugin-qrcode/tsconfig.build.json" }, + { "path": "../../packages/rspeedy/plugin-react/tsconfig.build.json" }, + ], +} diff --git a/package.json b/package.json index 645114aaa6..9efd05c8e6 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@rsbuild/core": "catalog:rsbuild", "@rslib/core": "^0.19.6", "@rspack/core": "catalog:rspack", + "@rstest/core": "catalog:rstest", "@svitejs/changesets-changelog-github-compact": "^1.2.0", "@tsconfig/node22": "^22.0.5", "@tsconfig/strictest": "^2.0.8", diff --git a/packages/lynx/gesture-runtime/vitest.config.ts b/packages/lynx/gesture-runtime/vitest.config.ts index 3e8c26be58..d7d6117429 100644 --- a/packages/lynx/gesture-runtime/vitest.config.ts +++ b/packages/lynx/gesture-runtime/vitest.config.ts @@ -1,8 +1,10 @@ -import { defineConfig, mergeConfig } from 'vitest/config'; -import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config'; +import { defineConfig } from 'vitest/config'; +import { vitestTestingLibraryPlugin } from '@lynx-js/react/testing-library/plugins'; -const defaultConfig = await createVitestConfig(); -const config = defineConfig({ +export default defineConfig({ + plugins: [ + vitestTestingLibraryPlugin(), + ], test: { name: 'lynx/gesture-runtime', setupFiles: ['__test__/utils/setup.ts'], @@ -13,5 +15,3 @@ const config = defineConfig({ exclude: ['__test__/utils/**'], }, }); - -export default mergeConfig(defaultConfig, config); diff --git a/packages/motion/vitest.config.ts b/packages/motion/vitest.config.ts index d403130d3b..4f96392ee9 100644 --- a/packages/motion/vitest.config.ts +++ b/packages/motion/vitest.config.ts @@ -1,8 +1,10 @@ -import { defineConfig, mergeConfig } from 'vitest/config'; -import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config'; +import { defineConfig } from 'vitest/config'; +import { vitestTestingLibraryPlugin } from '@lynx-js/react/testing-library/plugins'; -const defaultConfig = await createVitestConfig(); -const config = defineConfig({ +export default defineConfig({ + plugins: [ + vitestTestingLibraryPlugin(), + ], test: { include: ['__tests__/**/*.test.{js,ts,jsx,tsx}'], exclude: ['__tests__/utils/**'], @@ -11,5 +13,3 @@ const config = defineConfig({ }, }, }); - -export default mergeConfig(defaultConfig, config); diff --git a/packages/react/package.json b/packages/react/package.json index b30bbeded1..95909d372a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -101,9 +101,17 @@ "default": "./testing-library/dist/pure.js" }, "./testing-library/vitest-config": { - "types": "./testing-library/types/vitest-config.d.ts", + "types": "./testing-library/dist/vitest.config.d.ts", "default": "./testing-library/dist/vitest.config.js" }, + "./testing-library/plugins": { + "types": "./testing-library/dist/plugins/index.d.ts", + "default": "./testing-library/dist/plugins/index.js" + }, + "./testing-library/rstest-config": { + "types": "./testing-library/dist/rstest-config.d.ts", + "default": "./testing-library/dist/rstest-config.js" + }, "./package.json": "./package.json" }, "types": "./types/react.d.ts", diff --git a/packages/react/runtime/__test__/gesture/processGesture.test.ts b/packages/react/runtime/__test__/gesture/processGesture.test.ts new file mode 100644 index 0000000000..8eda850d23 --- /dev/null +++ b/packages/react/runtime/__test__/gesture/processGesture.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { processGesture } from '../../src/gesture/processGesture.js'; + +function createSerializedGesture(id: number) { + return { + id, + type: 0, + callbacks: { + onUpdate: { + _wkltId: 'bdd4:dd564:2', + }, + }, + __isSerialized: true, + }; +} + +function createSerializedComposedGesture(gestures: ReturnType[]) { + return { + type: -1, + gestures, + __isSerialized: true, + }; +} + +describe('processGesture', () => { + let setAttribute: ReturnType; + let setGestureDetector: ReturnType; + let removeGestureDetector: ReturnType; + let hydrateCtx: ReturnType; + + beforeEach(() => { + setAttribute = vi.fn(); + setGestureDetector = vi.fn(); + removeGestureDetector = vi.fn(); + hydrateCtx = vi.fn(); + + vi.stubGlobal('__SetAttribute', setAttribute); + vi.stubGlobal('__SetGestureDetector', setGestureDetector); + vi.stubGlobal('__RemoveGestureDetector', removeGestureDetector); + vi.stubGlobal('lynxWorkletImpl', { + _hydrateCtx: hydrateCtx, + _jsFunctionLifecycleManager: { + addRef: vi.fn(), + }, + _eventDelayImpl: { + runDelayedWorklet: vi.fn(), + }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sets detector on mount with expected params', () => { + const dom = {} as FiberElement; + const gesture = createSerializedGesture(1); + + processGesture(dom, gesture as any, undefined, false); + + expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', true); + expect(setAttribute).toHaveBeenCalledWith(dom, 'flatten', false); + expect(setGestureDetector).toHaveBeenCalledTimes(1); + expect(setGestureDetector).toHaveBeenCalledWith( + dom, + 1, + 0, + { + callbacks: [ + { + name: 'onUpdate', + callback: expect.objectContaining({ + _wkltId: 'bdd4:dd564:2', + }), + }, + ], + }, + { + waitFor: [], + simultaneous: [], + continueWith: [], + }, + ); + expect(removeGestureDetector).not.toHaveBeenCalled(); + }); + + it('skips attribute writes when domSet option is true', () => { + const dom = {} as FiberElement; + const gesture = createSerializedGesture(1); + + processGesture(dom, gesture as any, undefined, false, { domSet: true }); + + expect(setAttribute).not.toHaveBeenCalled(); + expect(setGestureDetector).toHaveBeenCalledTimes(1); + }); + + it('removes old detector first and then sets new detector on diff', () => { + const dom = {} as FiberElement; + const gestureA = createSerializedGesture(1); + const gestureB = createSerializedGesture(2); + + processGesture(dom, gestureA as any, undefined, false); + setAttribute.mockClear(); + setGestureDetector.mockClear(); + removeGestureDetector.mockClear(); + + processGesture(dom, gestureB as any, gestureA as any, false); + + expect(removeGestureDetector).toHaveBeenCalledTimes(1); + expect(removeGestureDetector).toHaveBeenCalledWith(dom, 1); + expect(setGestureDetector).toHaveBeenCalledTimes(1); + expect(setGestureDetector).toHaveBeenCalledWith( + dom, + 2, + 0, + { + callbacks: [ + { + name: 'onUpdate', + callback: expect.objectContaining({ + _wkltId: 'bdd4:dd564:2', + }), + }, + ], + }, + { + waitFor: [], + simultaneous: [], + continueWith: [], + }, + ); + }); + + it('removes only old gesture detector when gesture is deleted', () => { + const dom = {} as FiberElement; + const gestureA = createSerializedGesture(1); + const gestureB = createSerializedGesture(2); + + processGesture(dom, gestureA as any, undefined, false); + processGesture(dom, gestureB as any, gestureA as any, false); + setAttribute.mockClear(); + setGestureDetector.mockClear(); + removeGestureDetector.mockClear(); + + processGesture(dom, undefined as any, gestureB as any, false); + + expect(setGestureDetector).not.toHaveBeenCalled(); + expect(removeGestureDetector).toHaveBeenCalledTimes(1); + const removedIds = removeGestureDetector.mock.calls.map(([, id]) => id).sort((a, b) => a - b); + expect(removedIds).toEqual([2]); + expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null); + }); + + it('deduplicates same-id gestures in composed gesture diff', () => { + const dom = {} as FiberElement; + const gestureA = createSerializedGesture(1); + const gestureADuplicate = createSerializedGesture(1); + const composedGesture = createSerializedComposedGesture([gestureA, gestureADuplicate]); + + processGesture(dom, composedGesture as any, undefined, false); + + expect(setGestureDetector).toHaveBeenCalledTimes(1); + expect(setGestureDetector).toHaveBeenCalledWith( + dom, + 1, + 0, + { + callbacks: [ + { + name: 'onUpdate', + callback: expect.objectContaining({ + _wkltId: 'bdd4:dd564:2', + }), + }, + ], + }, + { + waitFor: [], + simultaneous: [], + continueWith: [], + }, + ); + }); + + it('removes all old ids when deleting composed gesture', () => { + const dom = {} as FiberElement; + const gestureA = createSerializedGesture(1); + const gestureB = createSerializedGesture(2); + const composed = createSerializedComposedGesture([gestureA, gestureB]); + + processGesture(dom, composed as any, undefined, false); + setAttribute.mockClear(); + setGestureDetector.mockClear(); + removeGestureDetector.mockClear(); + + processGesture(dom, undefined as any, composed as any, false); + + expect(setGestureDetector).not.toHaveBeenCalled(); + expect(removeGestureDetector).toHaveBeenCalledTimes(2); + const removedIds = removeGestureDetector.mock.calls.map(([, id]) => id).sort((a, b) => a - b); + expect(removedIds).toEqual([1, 2]); + expect(setAttribute).toHaveBeenCalledWith(dom, 'has-react-gesture', null); + }); + + it('removes stale detector ids before setting when gesture count shrinks on diff', () => { + const dom = {} as FiberElement; + const gestureA = createSerializedGesture(1); + const gestureB = createSerializedGesture(2); + const oldComposed = createSerializedComposedGesture([gestureA, gestureB]); + const newSingle = createSerializedGesture(3); + + processGesture(dom, oldComposed as any, undefined, false); + setAttribute.mockClear(); + setGestureDetector.mockClear(); + removeGestureDetector.mockClear(); + + processGesture(dom, newSingle as any, oldComposed as any, false); + + expect(removeGestureDetector).toHaveBeenCalledTimes(2); + expect(removeGestureDetector).toHaveBeenNthCalledWith(1, dom, 1); + expect(removeGestureDetector).toHaveBeenNthCalledWith(2, dom, 2); + expect(setGestureDetector).toHaveBeenCalledTimes(1); + expect(setGestureDetector).toHaveBeenCalledWith( + dom, + 3, + 0, + expect.any(Object), + { + waitFor: [], + simultaneous: [], + continueWith: [], + }, + ); + }); + + it('consumes old gestures one-to-one when ids are preserved and inserted', () => { + const dom = {} as FiberElement; + const oldGestureA = createSerializedGesture(1); + const oldGestureB = createSerializedGesture(2); + const newGestureB = createSerializedGesture(2); + const newGestureC = createSerializedGesture(3); + + oldGestureA.callbacks.onUpdate._wkltId = 'old-a'; + oldGestureB.callbacks.onUpdate._wkltId = 'old-b'; + newGestureB.callbacks.onUpdate._wkltId = 'new-b'; + newGestureC.callbacks.onUpdate._wkltId = 'new-c'; + + processGesture( + dom, + createSerializedComposedGesture([newGestureB, newGestureC]) as any, + createSerializedComposedGesture([oldGestureA, oldGestureB]) as any, + true, + ); + + expect(hydrateCtx).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ _wkltId: 'new-b' }), + expect.objectContaining({ _wkltId: 'old-b' }), + ); + expect(hydrateCtx).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ _wkltId: 'new-c' }), + expect.objectContaining({ _wkltId: 'old-a' }), + ); + }); +}); diff --git a/packages/react/runtime/__test__/snapshot/gesture.test.jsx b/packages/react/runtime/__test__/snapshot/gesture.test.jsx index 9de53ab92e..8cb1b0adf5 100644 --- a/packages/react/runtime/__test__/snapshot/gesture.test.jsx +++ b/packages/react/runtime/__test__/snapshot/gesture.test.jsx @@ -12,11 +12,43 @@ beforeAll(() => { setupPage(__CreatePage('0', 0)); injectUpdateMainThread(); replaceCommitHook(); + globalThis.lynxWorkletImpl = { + _refImpl: { + clearFirstScreenWorkletRefMap: vi.fn(), + }, + _runOnBackgroundDelayImpl: { + runDelayedBackgroundFunctions: vi.fn(), + }, + _hydrateCtx: vi.fn(), + _jsFunctionLifecycleManager: { + addRef: vi.fn(), + }, + _eventDelayImpl: { + runDelayedWorklet: vi.fn(), + clearDelayedWorklets: vi.fn(), + }, + }; }); beforeEach(() => { globalEnvManager.resetEnv(); SystemInfo.lynxSdkVersion = '999.999'; + globalThis.lynxWorkletImpl = { + _refImpl: { + clearFirstScreenWorkletRefMap: vi.fn(), + }, + _runOnBackgroundDelayImpl: { + runDelayedBackgroundFunctions: vi.fn(), + }, + _hydrateCtx: vi.fn(), + _jsFunctionLifecycleManager: { + addRef: vi.fn(), + }, + _eventDelayImpl: { + runDelayedWorklet: vi.fn(), + clearDelayedWorklets: vi.fn(), + }, + }; }); afterEach(() => { @@ -357,6 +389,8 @@ describe('Gesture', () => { globalEnvManager.switchToMainThread(); const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; globalThis[rLynxChange[0]](rLynxChange[1]); + const textElement = __root.__element_root.children[0].children[0]; + expect(elementTree.__GetGestureDetectorIds(textElement)).toEqual([3]); expect(__root.__element_root).toMatchInlineSnapshot(` { describe('Gesture in spread', () => { it('normal gesture', async function() { + const spySetGesture = vi.spyOn(globalThis, '__SetGestureDetector'); function Comp() { const gesture = { id: 1, @@ -696,6 +731,29 @@ describe('Gesture in spread', () => { // Main Thread Render { + const textElement = __root.__element_root.children[0].children[0]; + expect(spySetGesture).toHaveBeenCalledTimes(1); + expect(spySetGesture).toHaveBeenCalledWith( + textElement, + 1, + 0, + { + callbacks: [ + { + name: 'onUpdate', + callback: expect.objectContaining({ + _wkltId: 'bdd4:dd564:2', + }), + }, + ], + }, + { + waitFor: [], + simultaneous: [], + continueWith: [], + }, + ); + expect(__root.__element_root).toMatchInlineSnapshot(` { } }); it('update gesture', async function() { + const spySetGesture = vi.spyOn(globalThis, '__SetGestureDetector'); + const spyRemoveGesture = vi.spyOn(globalThis, '__RemoveGestureDetector'); let _gesture = { id: 1, type: 0, @@ -804,6 +864,8 @@ describe('Gesture in spread', () => { { globalEnvManager.switchToBackground(); lynx.getNativeApp().callLepusMethod.mockClear(); + spySetGesture.mockClear(); + spyRemoveGesture.mockClear(); _gesture = { ..._gesture, @@ -815,45 +877,32 @@ describe('Gesture in spread', () => { globalEnvManager.switchToMainThread(); const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; globalThis[rLynxChange[0]](rLynxChange[1]); - - expect(__root.__element_root).toMatchInlineSnapshot(` - - - - - - - - `); + const textElement = __root.__element_root.children[0].children[0]; + + expect(spySetGesture).toHaveBeenCalledTimes(1); + const [setTarget, setGestureId, setGestureType, setConfig, setRelationMap] = spySetGesture.mock.calls[0]; + expect(setTarget).toBe(textElement); + expect(setGestureType).toBe(0); + expect(typeof setGestureId).toBe('number'); + expect(setConfig).toMatchObject({ + callbacks: [ + { + name: 'onUpdate', + callback: expect.objectContaining({ + _wkltId: 'bdd4:dd564:2', + }), + }, + ], + }); + expect(setRelationMap).toEqual({ + waitFor: [], + simultaneous: [], + continueWith: [], + }); + expect(spyRemoveGesture).toHaveBeenCalledTimes(1); + expect(spyRemoveGesture).toHaveBeenCalledWith(textElement, 1); + expect(elementTree.__GetGestureDetectorIds(textElement)).toEqual([setGestureId]); + expect(textElement.props['has-react-gesture']).toBe(true); } }); it('insert gesture', async function() { @@ -981,6 +1030,8 @@ describe('Gesture in spread', () => { } }); it('remove gesture', async function() { + const spySetGesture = vi.spyOn(globalThis, '__SetGestureDetector'); + const spyRemoveGesture = vi.spyOn(globalThis, '__RemoveGestureDetector'); let _gesture = { id: 1, type: 0, @@ -1044,10 +1095,12 @@ describe('Gesture in spread', () => { const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; globalThis[rLynxChange[0]](rLynxChange[1]); } - // update + // remove { globalEnvManager.switchToBackground(); lynx.getNativeApp().callLepusMethod.mockClear(); + spySetGesture.mockClear(); + spyRemoveGesture.mockClear(); _gesture = undefined; @@ -1056,20 +1109,215 @@ describe('Gesture in spread', () => { globalEnvManager.switchToMainThread(); const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; globalThis[rLynxChange[0]](rLynxChange[1]); + const textElement = __root.__element_root.children[0].children[0]; + expect(spySetGesture).not.toHaveBeenCalled(); + expect(spyRemoveGesture).toHaveBeenCalledTimes(1); + expect(spyRemoveGesture).toHaveBeenCalledWith(textElement, 1); + expect(textElement.props['has-react-gesture']).toBeUndefined(); + expect(elementTree.__GetGestureDetectorIds(textElement).includes(1)).toBe(false); + } + }); + it('remove stale detector ids when gesture count shrinks on diff', async function() { + const spySetGesture = vi.spyOn(globalThis, '__SetGestureDetector'); + const spyRemoveGesture = vi.spyOn(globalThis, '__RemoveGestureDetector'); - expect(__root.__element_root).toMatchInlineSnapshot(` - - - - - - - - `); + const createGesture = (id) => ({ + id, + type: 0, + callbacks: { + onUpdate: { + _wkltId: 'bdd4:dd564:2', + }, + }, + __isGesture: true, + toJSON() { + const { toJSON, ...rest } = this; + return { + ...rest, + __isSerialized: true, + }; + }, + }); + + let useComposed = true; + + function Comp() { + const gestureA = createGesture(1); + const gestureB = createGesture(2); + const singleGesture = createGesture(3); + const composedGesture = { + type: -1, + gestures: [gestureA, gestureB], + __isGesture: true, + toJSON() { + return { + type: this.type, + gestures: this.gestures.map(gesture => gesture.toJSON()), + __isSerialized: true, + }; + }, + }; + + const props = { + 'main-thread:gesture': useComposed ? composedGesture : singleGesture, + }; + + return ( + + 1 + + ); + } + + // main thread render + { + __root.__jsx = ; + renderPage(); + } + + // background render + { + globalEnvManager.switchToBackground(); + render(, __root); + } + + // hydrate + { + lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + + globalEnvManager.switchToMainThread(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + } + + // update: composed(2) -> single(1), remove stale detector ids before setting new one + { + globalEnvManager.switchToBackground(); + lynx.getNativeApp().callLepusMethod.mockClear(); + spySetGesture.mockClear(); + spyRemoveGesture.mockClear(); + useComposed = false; + + render(, __root); + + globalEnvManager.switchToMainThread(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + const textElement = __root.__element_root.children[0].children[0]; + + expect(spySetGesture).toHaveBeenCalledTimes(1); + expect(spyRemoveGesture).toHaveBeenCalledTimes(2); + expect(spyRemoveGesture).toHaveBeenNthCalledWith(1, textElement, 1); + expect(spyRemoveGesture).toHaveBeenNthCalledWith(2, textElement, 2); + expect(elementTree.__GetGestureDetectorIds(textElement)).toEqual([3]); + } + }); + it('updates reordered composed gesture detectors with one-to-one old matches', async function() { + const spySetGesture = vi.spyOn(globalThis, '__SetGestureDetector'); + + const createGesture = (id, wkltId) => ({ + id, + type: 0, + callbacks: { + onUpdate: { + _wkltId: wkltId, + }, + }, + __isGesture: true, + toJSON() { + const { toJSON, ...rest } = this; + return { + ...rest, + __isSerialized: true, + }; + }, + }); + + let firstRender = true; + + function Comp() { + const oldGestureA = createGesture(1, 'old-a'); + const oldGestureB = createGesture(2, 'old-b'); + const newGestureB = createGesture(2, 'new-b'); + const newGestureC = createGesture(3, 'new-c'); + const gesture = { + type: -1, + gestures: firstRender ? [oldGestureA, oldGestureB] : [newGestureB, newGestureC], + __isGesture: true, + toJSON() { + return { + type: this.type, + gestures: this.gestures.map(subGesture => subGesture.toJSON()), + __isSerialized: true, + }; + }, + }; + + return ( + + 1 + + ); + } + + { + __root.__jsx = ; + renderPage(); + } + + { + globalEnvManager.switchToBackground(); + render(, __root); + } + + { + lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + + globalEnvManager.switchToMainThread(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + } + + { + globalEnvManager.switchToBackground(); + lynx.getNativeApp().callLepusMethod.mockClear(); + spySetGesture.mockClear(); + firstRender = false; + + render(, __root); + + globalEnvManager.switchToMainThread(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + + expect(spySetGesture).toHaveBeenNthCalledWith( + 1, + expect.anything(), + 2, + 0, + expect.objectContaining({ + callbacks: [ + expect.objectContaining({ + callback: expect.objectContaining({ _wkltId: 'new-b' }), + }), + ], + }), + expect.any(Object), + ); + expect(spySetGesture).toHaveBeenNthCalledWith( + 2, + expect.anything(), + 3, + 0, + expect.objectContaining({ + callbacks: [ + expect.objectContaining({ + callback: expect.objectContaining({ _wkltId: 'new-c' }), + }), + ], + }), + expect.any(Object), + ); } }); }); diff --git a/packages/react/runtime/__test__/utils/nativeMethod.ts b/packages/react/runtime/__test__/utils/nativeMethod.ts index 60e85ff578..e0050c7ca3 100644 --- a/packages/react/runtime/__test__/utils/nativeMethod.ts +++ b/packages/react/runtime/__test__/utils/nativeMethod.ts @@ -19,6 +19,15 @@ interface ElementOptions { export let uiSignNext = 0; export const parentMap = new WeakMap(); +let gestureDetectorMap = new WeakMap< + Element, + Map; + }> +>(); // export const elementPrototype = Object.create(null); export const options: ElementOptions = {}; @@ -185,12 +194,40 @@ export const elementTree = new (class { } __SetGestureDetector(e: Element, id: number, type: number, config: any, relationMap: Record) { - e.props.gesture = { + const detector = { id, type, config, relationMap, }; + let detectors = gestureDetectorMap.get(e); + if (!detectors) { + detectors = new Map(); + gestureDetectorMap.set(e, detectors); + } + detectors.set(id, detector); + e.props.gesture = detector; + } + + __RemoveGestureDetector(e: Element, id: number) { + const detectors = gestureDetectorMap.get(e); + detectors?.delete(id); + if (e.props.gesture?.id === id) { + const latestDetector = detectors ? [...detectors.values()].at(-1) : undefined; + if (latestDetector) { + e.props.gesture = latestDetector; + } else { + delete e.props.gesture; + } + } + } + + __GetGestureDetector(e: Element, id: number) { + return gestureDetectorMap.get(e)?.get(id); + } + + __GetGestureDetectorIds(e: Element) { + return [...(gestureDetectorMap.get(e)?.keys() ?? [])]; } __GetDataset(e: Element) { @@ -317,6 +354,7 @@ export const elementTree = new (class { clear() { this.root = undefined; uiSignNext = 0; + gestureDetectorMap = new WeakMap(); } toTree() { diff --git a/packages/react/runtime/src/alog/elementPAPICall.ts b/packages/react/runtime/src/alog/elementPAPICall.ts index 6288e589c1..0f7b78c263 100644 --- a/packages/react/runtime/src/alog/elementPAPICall.ts +++ b/packages/react/runtime/src/alog/elementPAPICall.ts @@ -42,6 +42,7 @@ const fiberElementPAPINameList = [ '__OnLifecycleEvent', '__QueryComponent', '__SetGestureDetector', + '__RemoveGestureDetector', ]; export function initElementPAPICallAlog(globalWithIndex: Record = globalThis): void { diff --git a/packages/react/runtime/src/gesture/processGesture.ts b/packages/react/runtime/src/gesture/processGesture.ts index fc162f502d..6273f9584a 100644 --- a/packages/react/runtime/src/gesture/processGesture.ts +++ b/packages/react/runtime/src/gesture/processGesture.ts @@ -10,6 +10,108 @@ function isSerializedGesture(gesture: GestureKind): boolean { return gesture.__isSerialized ?? false; } +function getSerializedBaseGesture(gesture: GestureKind | undefined): BaseGesture | undefined { + if (!gesture || !isSerializedGesture(gesture)) { + return undefined; + } + + if (gesture.type !== GestureTypeInner.COMPOSED) { + return gesture as BaseGesture; + } + + return undefined; +} + +function appendUniqueSerializedBaseGestures( + gesture: GestureKind | undefined, + out: BaseGesture[], + seenIds: Set, +): void { + if (!gesture || !isSerializedGesture(gesture)) { + return; + } + + if (gesture.type === GestureTypeInner.COMPOSED) { + for (const subGesture of (gesture as ComposedGesture).gestures) { + appendUniqueSerializedBaseGestures(subGesture, out, seenIds); + } + return; + } + + const baseGesture = gesture as BaseGesture; + if (seenIds.has(baseGesture.id)) { + return; + } + seenIds.add(baseGesture.id); + out.push(baseGesture); +} + +function collectOldGestureInfo( + oldGesture: GestureKind | undefined, +): { + uniqOldBaseGestures: BaseGesture[]; + oldBaseGesturesById: Map; +} { + const uniqOldBaseGestures: BaseGesture[] = []; + const oldBaseGesturesById = new Map(); + appendOldGestureInfo(oldGesture, uniqOldBaseGestures, oldBaseGesturesById); + + return { + uniqOldBaseGestures, + oldBaseGesturesById, + }; +} + +function appendOldGestureInfo( + gesture: GestureKind | undefined, + out: BaseGesture[], + byId: Map, +): void { + if (!gesture || !isSerializedGesture(gesture)) { + return; + } + + if (gesture.type === GestureTypeInner.COMPOSED) { + for (const subGesture of (gesture as ComposedGesture).gestures) { + appendOldGestureInfo(subGesture, out, byId); + } + return; + } + + const oldBaseGesture = gesture as BaseGesture; + if (!byId.has(oldBaseGesture.id)) { + byId.set(oldBaseGesture.id, oldBaseGesture); + out.push(oldBaseGesture); + } +} + +function consumeOldBaseGesture( + baseGesture: BaseGesture, + uniqOldBaseGestures: BaseGesture[], + oldBaseGesturesById: Map, +): BaseGesture | undefined { + const idMatchedOldBaseGesture = oldBaseGesturesById.get(baseGesture.id); + if (idMatchedOldBaseGesture) { + oldBaseGesturesById.delete(baseGesture.id); + return idMatchedOldBaseGesture; + } + + const fallbackOldBaseGesture = uniqOldBaseGestures.find(oldBaseGesture => oldBaseGesturesById.has(oldBaseGesture.id)); + if (!fallbackOldBaseGesture) { + return undefined; + } + + oldBaseGesturesById.delete(fallbackOldBaseGesture.id); + return fallbackOldBaseGesture; +} + +function removeGestureDetector(dom: FiberElement, id: number): void { + // Keep compatibility with old runtimes where remove API is not exposed. + if (typeof __RemoveGestureDetector === 'function') { + __RemoveGestureDetector(dom, id); + } +} + function getGestureInfo( gesture: BaseGesture, oldGesture: BaseGesture | undefined, @@ -58,24 +160,64 @@ export function processGesture( domSet: boolean; }, ): void { - if (!gesture || !isSerializedGesture(gesture)) { + const domSet = gestureOptions?.domSet === true; + const { uniqOldBaseGestures, oldBaseGesturesById } = collectOldGestureInfo(oldGesture); + + // Fast path for the most common case: single base gesture update. + const singleBaseGesture = getSerializedBaseGesture(gesture); + const singleOldBaseGesture = getSerializedBaseGesture(oldGesture); + if (singleBaseGesture && (!oldGesture || singleOldBaseGesture)) { + if (!domSet) { + __SetAttribute(dom, 'has-react-gesture', true); + __SetAttribute(dom, 'flatten', false); + } + + if (singleOldBaseGesture) { + // On update, remove old detector first to avoid stale callbacks. + removeGestureDetector(dom, singleOldBaseGesture.id); + } + + const { config, relationMap } = getGestureInfo(singleBaseGesture, singleOldBaseGesture, isFirstScreen, dom); + __SetGestureDetector( + dom, + singleBaseGesture.id, + singleBaseGesture.type, + config, + relationMap, + ); + return; + } + + const uniqBaseGestures: BaseGesture[] = []; + appendUniqueSerializedBaseGestures(gesture, uniqBaseGestures, new Set()); + + if (uniqBaseGestures.length === 0) { + for (const oldBaseGesture of oldBaseGesturesById.values()) { + removeGestureDetector(dom, oldBaseGesture.id); + } + + if (!domSet) { + __SetAttribute(dom, 'has-react-gesture', null); + } return; } - if (!(gestureOptions && gestureOptions.domSet)) { + if (!domSet) { __SetAttribute(dom, 'has-react-gesture', true); __SetAttribute(dom, 'flatten', false); } - if (gesture.type === GestureTypeInner.COMPOSED) { - for (const [index, subGesture] of (gesture as ComposedGesture).gestures.entries()) { - processGesture(dom, subGesture, (oldGesture as ComposedGesture)?.gestures[index], isFirstScreen, { - domSet: true, - }); - } - } else { - const baseGesture = gesture as BaseGesture; - const oldBaseGesture = oldGesture as BaseGesture | undefined; + // On update, remove old detectors first to avoid stale callbacks. + for (const oldBaseGesture of oldBaseGesturesById.values()) { + removeGestureDetector(dom, oldBaseGesture.id); + } + + for (const baseGesture of uniqBaseGestures) { + const oldBaseGesture = consumeOldBaseGesture( + baseGesture, + uniqOldBaseGestures, + oldBaseGesturesById, + ); const { config, relationMap } = getGestureInfo(baseGesture, oldBaseGesture, isFirstScreen, dom); __SetGestureDetector( diff --git a/packages/react/runtime/src/renderToOpcodes/index.ts b/packages/react/runtime/src/renderToOpcodes/index.ts index c6a9327135..4381d53586 100644 --- a/packages/react/runtime/src/renderToOpcodes/index.ts +++ b/packages/react/runtime/src/renderToOpcodes/index.ts @@ -292,8 +292,9 @@ function _renderToString( : into.__firstChild, ); if (e && typeof e === 'object' && e.then && component && /* _childDidSuspend */ component[CHILD_DID_SUSPEND]) { - component.setState({ /* _suspended */ __a: true }); - + component[NEXT_STATE] = assign({}, component[NEXT_STATE], { + /* _suspended */ __a: true, + }); if (component[DIRTY]) { rendered = renderClassComponent(vnode, context); component = vnode[COMPONENT]; diff --git a/packages/react/runtime/types/types.d.ts b/packages/react/runtime/types/types.d.ts index c69a0ed1c2..14b7072d57 100644 --- a/packages/react/runtime/types/types.d.ts +++ b/packages/react/runtime/types/types.d.ts @@ -117,6 +117,7 @@ declare global { config: any, relationMap: Record, ): void; + declare function __RemoveGestureDetector(node: FiberElement, id: number): void; declare interface FiberElement {} diff --git a/packages/react/testing-library/.npmignore b/packages/react/testing-library/.npmignore index cb433b7a07..b1e6104efa 100644 --- a/packages/react/testing-library/.npmignore +++ b/packages/react/testing-library/.npmignore @@ -1,4 +1,6 @@ * -!dist/* -!dist/env/* +* +!dist +!dist/**/* +!types !types/* diff --git a/packages/react/testing-library/README.md b/packages/react/testing-library/README.md index b14b28d9e6..50e173cdeb 100644 --- a/packages/react/testing-library/README.md +++ b/packages/react/testing-library/README.md @@ -8,30 +8,69 @@ Similar to [react-testing-library](https://github.com/testing-library/react-test ## Setup +### Rstest + +Setup rstest with `@lynx-js/react/testing-library/rstest-config`. + +Recommended for library projects: + +```ts +import { defineConfig } from '@rstest/core'; +import { withDefaultConfig } from '@lynx-js/react/testing-library/rstest-config'; + +export default defineConfig({ + extends: withDefaultConfig(), +}); +``` + +Use `withLynxConfig` when you want to reuse your app's `lynx.config.ts`: + +```ts +// rstest.config.ts +import { defineConfig } from '@rstest/core'; +import { withLynxConfig } from '@lynx-js/react/testing-library/rstest-config'; + +export default defineConfig({ + extends: withLynxConfig(), +}); +``` + +Difference between `withLynxConfig` and `withDefaultConfig`: + +- `withLynxConfig`: app-oriented. Loads your `lynx.config.ts` and converts it to rstest config, so rspeedy/lynx settings are reused in tests. +- `withDefaultConfig`: library-oriented. Only applies testing-library defaults (`jsdom`, setup files, globals) and lets you provide the rest via `modifyRstestConfig`. + +Then you can start writing tests and run them with rstest! + +For more usage detail, see https://rstest.rs/ + +### Vitest + Setup vitest: ```js -// vitest.config.js -import { defineConfig, mergeConfig } from 'vitest/config'; -import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config'; +import { defineConfig } from 'vitest/config'; +import { vitestTestingLibraryPlugin } from '@lynx-js/react/testing-library/plugins'; -const defaultConfig = createVitestConfig(); -const config = defineConfig({ +export default defineConfig({ + plugins: [ + vitestTestingLibraryPlugin(), + ], test: { // ... }, }); - -export default mergeConfig(defaultConfig, config); ``` Then you can start writing tests and run them with vitest! +`createVitestConfig` is still supported for backward compatibility, but is deprecated. + ## Usage -```js +```jsx import '@testing-library/jest-dom'; -import { test, expect } from 'vitest'; +import { test, expect } from 'vitest'; // or '@rstest/core' import { render } from '@lynx-js/react/testing-library'; test('renders options.wrapper around node', async () => { diff --git a/packages/react/testing-library/package.json b/packages/react/testing-library/package.json index cedf70b6a0..cad2bd3569 100644 --- a/packages/react/testing-library/package.json +++ b/packages/react/testing-library/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "rslib build", "dev": "rslib build --watch", + "rstest": "rstest", "test": "npm run test:base && npm run test:3.1", "test:3.1": "vitest --config vitest.3.1.config.ts", "test:base": "vitest", @@ -13,7 +14,11 @@ }, "devDependencies": { "@lynx-js/react": "workspace:*", + "@lynx-js/react-rsbuild-plugin": "workspace:*", + "@lynx-js/rspeedy": "workspace:*", "@lynx-js/testing-environment": "workspace:*", + "@rsbuild/core": "catalog:rsbuild", + "@rstest/adapter-rsbuild": "^0.2.3", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1" } diff --git a/packages/react/testing-library/rslib.config.ts b/packages/react/testing-library/rslib.config.ts index 10790990fe..117f815cda 100644 --- a/packages/react/testing-library/rslib.config.ts +++ b/packages/react/testing-library/rslib.config.ts @@ -9,12 +9,14 @@ export default defineConfig({ { format: 'esm', syntax: 'es2022', - dts: false, + dts: true, bundle: true, source: { entry: { 'pure': './src/pure.jsx', - 'env/vitest': './src/env/vitest.ts', + 'env/index': './src/env/index.ts', + 'plugins/index': './src/plugins/index.ts', + 'rstest-config': './src/rstest-config.ts', }, }, output: { @@ -23,6 +25,9 @@ export default defineConfig({ /^\.\.\/\.\.\/runtime\/lib/, /^preact/, /^vitest/, + '@rstest/core', + '@rsbuild/core', + '@lynx-js/rspeedy', ], }, }, @@ -33,9 +38,13 @@ export default defineConfig({ bundle: false, source: { entry: { - 'index': './src/index.jsx', - 'vitest.config': './src/vitest.config.js', - 'vitest-global-setup': './src/vitest-global-setup.js', + 'index': [ + './src/index.jsx', + './src/vitest.config.ts', + './src/env/vitest.ts', + './src/env/rstest.ts', + './src/setupFiles/**/*.js', + ], }, }, output: { diff --git a/packages/react/testing-library/rstest.config.ts b/packages/react/testing-library/rstest.config.ts new file mode 100644 index 0000000000..803991dfd7 --- /dev/null +++ b/packages/react/testing-library/rstest.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from '@rstest/core'; +import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; +import { withDefaultConfig } from './src/rstest-config.ts'; + +export default defineConfig({ + extends: withDefaultConfig({ + modifyRstestConfig(config) { + return { + ...config, + tools: { + swc: { + jsc: { + transform: { + useDefineForClassFields: true, + }, + }, + }, + }, + plugins: [ + ...(config.plugins || []), + pluginReactLynx(), + ], + source: { + ...config.source, + define: { + ...config.source?.define, + __ALOG__: 'true', + }, + }, + resolve: { + ...config.resolve, + // in order to make our test case work for + // both vitest and rstest, we need to alias + // `vitest` to `@rstest/core` + alias: { + ...config.resolve?.alias, + vitest: require.resolve('./vitest-polyfill.cjs'), + }, + }, + include: ['src/**/*.test.{js,jsx,ts,tsx}', '!src/__tests__/3.1/**/*.{js,jsx,ts,tsx}'], + }; + }, + }), +}); diff --git a/packages/react/testing-library/src/__tests__/act.test.jsx b/packages/react/testing-library/src/__tests__/act.test.jsx index fbd2970ccf..48706037d6 100644 --- a/packages/react/testing-library/src/__tests__/act.test.jsx +++ b/packages/react/testing-library/src/__tests__/act.test.jsx @@ -201,7 +201,7 @@ test('fireEvent triggers useEffect calls', async () => { [ "rLynxChange", { - "data": "{"patchList":[{"snapshotPatch":[0,"__snapshot_e8d0a_test_4",2,4,2,[1],0,null,3,4,3,[0],1,2,3,null,1,-1,2,null],"id":2}]}", + "data": "{"patchList":[{"snapshotPatch":[0,"__snapshot_268b9_test_4",2,4,2,[1],0,null,3,4,3,[0],1,2,3,null,1,-1,2,null],"id":2}]}", "patchOptions": { "isHydration": true, "pipelineOptions": { @@ -236,7 +236,7 @@ test('fireEvent triggers useEffect calls', async () => { ], "extraProps": undefined, "id": 2, - "type": "__snapshot_e8d0a_test_4", + "type": "__snapshot_268b9_test_4", "values": [ "2:0:", ], @@ -261,7 +261,7 @@ test('fireEvent triggers useEffect calls', async () => { ], "extraProps": undefined, "id": 2, - "type": "__snapshot_e8d0a_test_4", + "type": "__snapshot_268b9_test_4", "values": [ "2:0:", ], @@ -285,7 +285,7 @@ test('fireEvent triggers useEffect calls', async () => { [ "rLynxChange", { - "data": "{"patchList":[{"snapshotPatch":[0,"__snapshot_e8d0a_test_4",2,4,2,[1],0,null,3,4,3,[0],1,2,3,null,1,-1,2,null],"id":2}]}", + "data": "{"patchList":[{"snapshotPatch":[0,"__snapshot_268b9_test_4",2,4,2,[1],0,null,3,4,3,[0],1,2,3,null,1,-1,2,null],"id":2}]}", "patchOptions": { "isHydration": true, "pipelineOptions": { diff --git a/packages/react/testing-library/src/__tests__/alog.test.jsx b/packages/react/testing-library/src/__tests__/alog.test.jsx index 2ebadd6119..e4d1acce1a 100644 --- a/packages/react/testing-library/src/__tests__/alog.test.jsx +++ b/packages/react/testing-library/src/__tests__/alog.test.jsx @@ -154,7 +154,98 @@ describe('alog', () => { "[MainThread Component Render] name: App", ], [ - "[ReactLynxDebug] FiberElement API call #32: __OnLifecycleEvent(["rLynxFirstScreen", {"root":"{\\"id\\":-1,\\"type\\":\\"root\\",\\"children\\":[{\\"id\\":-2,\\"type\\":\\"__snapshot_426db_test_1\\",\\"values\\":[\\"-2:0:\\",\\"-2:1:\\"],\\"children\\":[{\\"id\\":-3,\\"type\\":\\"wrapper\\",\\"children\\":[{\\"id\\":-4,\\"type\\":null,\\"values\\":[0]}]},{\\"id\\":-5,\\"type\\":\\"wrapper\\",\\"children\\":[{\\"id\\":-6,\\"type\\":\\"__snapshot_426db_test_2\\"},{\\"id\\":-7,\\"type\\":\\"__snapshot_426db_test_3\\"}]}]}]}","jsReadyEventIdSwap":{}}])", + "[ReactLynxDebug] FiberElement API call #32: __OnLifecycleEvent(["rLynxFirstScreen", {"root":"{\\"id\\":-1,\\"type\\":\\"root\\",\\"children\\":[{\\"id\\":-2,\\"type\\":\\"__snapshot_d6fb6_test_1\\",\\"values\\":[\\"-2:0:\\",\\"-2:1:\\"],\\"children\\":[{\\"id\\":-3,\\"type\\":\\"wrapper\\",\\"children\\":[{\\"id\\":-4,\\"type\\":null,\\"values\\":[0]}]},{\\"id\\":-5,\\"type\\":\\"wrapper\\",\\"children\\":[{\\"id\\":-6,\\"type\\":\\"__snapshot_d6fb6_test_2\\"},{\\"id\\":-7,\\"type\\":\\"__snapshot_d6fb6_test_3\\"}]}]}]}","jsReadyEventIdSwap":{}}])", + ], + [ + "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_d6fb6_test_2, __id: 6", + ], + [ + "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_d6fb6_test_3, __id: 7", + ], + [ + "[BackgroundThread Component Render] name: App, uniqID: __snapshot_d6fb6_test_1, __id: 2", + ], + [ + "[BackgroundThread Component Render] name: Fragment, uniqID: __snapshot_d6fb6_test_1, __id: 2", + ], + [ + "[ReactLynxDebug] MTS -> BTS OnLifecycleEvent: + { + "root": { + "id": -1, + "type": "root", + "children": [ + { + "id": -2, + "type": "__snapshot_d6fb6_test_1", + "values": [ + "-2:0:", + "-2:1:" + ], + "children": [ + { + "id": -3, + "type": "wrapper", + "children": [ + { + "id": -4, + "type": null, + "values": [ + 0 + ] + } + ] + }, + { + "id": -5, + "type": "wrapper", + "children": [ + { + "id": -6, + "type": "__snapshot_d6fb6_test_2" + }, + { + "id": -7, + "type": "__snapshot_d6fb6_test_3" + } + ] + } + ] + } + ] + }, + "jsReadyEventIdSwap": {} + }", + ], + [ + "[ReactLynxDebug] SnapshotInstance tree for first screen hydration: + | -1(root): undefined + | -2(__snapshot_d6fb6_test_1): ["-2:0:","-2:1:"] + | -3(wrapper): undefined + | -4(null): [0] + | -5(wrapper): undefined + | -6(__snapshot_d6fb6_test_2): undefined + | -7(__snapshot_d6fb6_test_3): undefined", + ], + [ + "[ReactLynxDebug] BackgroundSnapshotInstance tree before hydration: + | 1(root): undefined + | 2(__snapshot_d6fb6_test_1): [null,null] + | 3(wrapper): undefined + | 4(null): [0] + | 5(wrapper): undefined + | 6(__snapshot_d6fb6_test_2): undefined + | 7(__snapshot_d6fb6_test_3): undefined", + ], + [ + "[ReactLynxDebug] BackgroundSnapshotInstance after hydration: + | -1(root): undefined + | -2(__snapshot_d6fb6_test_1): [null,null] + | -3(wrapper): undefined + | -4(null): [0] + | -5(wrapper): undefined + | -6(__snapshot_d6fb6_test_2): undefined + | -7(__snapshot_d6fb6_test_3): undefined", ], [ "[ReactLynxDebug] BTS -> MTS updateMainThread: @@ -183,6 +274,33 @@ describe('alog', () => { [ "[ReactLynxDebug] FiberElement API call #33: __FlushElementTree(PAGE#0, {"pipelineOptions":{"pipelineID":"pipelineID","needTimestamps":true,"pipelineOrigin":"reactLynxHydrate","dsl":"reactLynx","stage":"hydrate"}})", ], + [ + "[ReactLynxDebug] BTS received event: + { + "handlerName": "-2:0:", + "type": "bindEvent:tap", + "snapshotType": "__snapshot_d6fb6_test_1", + "jsFunctionName": "" + }", + ], + [ + "[ReactLynxDebug] BTS received event: + { + "handlerName": "-2:1:", + "type": "catchEvent:focus", + "snapshotType": "__snapshot_d6fb6_test_1", + "jsFunctionName": "handleFocus" + }", + ], + [ + "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_d6fb6_test_2, __id: -6", + ], + [ + "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_d6fb6_test_3, __id: -7", + ], + [ + "[BackgroundThread Component Render] name: App, uniqID: __snapshot_d6fb6_test_1, __id: -2", + ], [ "[ReactLynxDebug] BTS -> MTS updateMainThread: { @@ -224,16 +342,121 @@ describe('alog', () => { expect(lynxTestingEnv.backgroundThread.console.alog.mock.calls).toMatchInlineSnapshot(` [ [ - "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_426db_test_2, __id: 6", + "[ReactLynxDebug] FiberElement API call #1: __CreatePage("0", 0) => PAGE#0", + ], + [ + "[ReactLynxDebug] FiberElement API call #2: __GetElementUniqueID(PAGE#0) => 0", + ], + [ + "[ReactLynxDebug] FiberElement API call #3: __SetCSSId([PAGE#0], 0)", + ], + [ + "[ReactLynxDebug] FiberElement API call #4: __CreateView(0) => VIEW#1", + ], + [ + "[ReactLynxDebug] FiberElement API call #5: __CreateText(0) => TEXT#2", + ], + [ + "[ReactLynxDebug] FiberElement API call #6: __AddDataset(TEXT#2, "testid", "count-text")", + ], + [ + "[ReactLynxDebug] FiberElement API call #7: __AppendElement(VIEW#1, TEXT#2)", + ], + [ + "[ReactLynxDebug] FiberElement API call #8: __CreateRawText("count: ") => #text#3", + ], + [ + "[ReactLynxDebug] FiberElement API call #9: __AppendElement(TEXT#2, #text#3)", + ], + [ + "[ReactLynxDebug] FiberElement API call #10: __CreateWrapperElement(0) => WRAPPER#4", + ], + [ + "[ReactLynxDebug] FiberElement API call #11: __AppendElement(TEXT#2, WRAPPER#4)", + ], + [ + "[ReactLynxDebug] FiberElement API call #12: __CreateWrapperElement(0) => WRAPPER#5", + ], + [ + "[ReactLynxDebug] FiberElement API call #13: __AppendElement(VIEW#1, WRAPPER#5)", + ], + [ + "[ReactLynxDebug] FiberElement API call #14: __AppendElement(PAGE#0, VIEW#1)", + ], + [ + "[ReactLynxDebug] FiberElement API call #15: __AddEvent(TEXT#2, "bindEvent", "tap", "-2:0:")", + ], + [ + "[ReactLynxDebug] FiberElement API call #16: __AddEvent(TEXT#2, "catchEvent", "focus", "-2:1:")", + ], + [ + "[ReactLynxDebug] FiberElement API call #17: __CreateWrapperElement(0) => WRAPPER#6", + ], + [ + "[ReactLynxDebug] FiberElement API call #18: __ReplaceElement(WRAPPER#6, WRAPPER#4)", + ], + [ + "[ReactLynxDebug] FiberElement API call #19: __CreateRawText("") => #text#7", + ], + [ + "[ReactLynxDebug] FiberElement API call #20: __SetAttribute(#text#7, "text", 0)", + ], + [ + "[ReactLynxDebug] FiberElement API call #21: __AppendElement(WRAPPER#6, #text#7)", + ], + [ + "[ReactLynxDebug] FiberElement API call #22: __CreateWrapperElement(0) => WRAPPER#8", + ], + [ + "[ReactLynxDebug] FiberElement API call #23: __ReplaceElement(WRAPPER#8, WRAPPER#5)", + ], + [ + "[ReactLynxDebug] FiberElement API call #24: __CreateView(0) => VIEW#9", + ], + [ + "[ReactLynxDebug] FiberElement API call #25: __CreateRawText("Class Component") => #text#10", + ], + [ + "[ReactLynxDebug] FiberElement API call #26: __AppendElement(VIEW#9, #text#10)", + ], + [ + "[ReactLynxDebug] FiberElement API call #27: __AppendElement(WRAPPER#8, VIEW#9)", + ], + [ + "[MainThread Component Render] name: ClassComponent", + ], + [ + "[ReactLynxDebug] FiberElement API call #28: __CreateView(0) => VIEW#11", + ], + [ + "[ReactLynxDebug] FiberElement API call #29: __CreateRawText("Function Component") => #text#12", + ], + [ + "[ReactLynxDebug] FiberElement API call #30: __AppendElement(VIEW#11, #text#12)", + ], + [ + "[ReactLynxDebug] FiberElement API call #31: __AppendElement(WRAPPER#8, VIEW#11)", + ], + [ + "[MainThread Component Render] name: FunctionComponent", + ], + [ + "[MainThread Component Render] name: App", + ], + [ + "[ReactLynxDebug] FiberElement API call #32: __OnLifecycleEvent(["rLynxFirstScreen", {"root":"{\\"id\\":-1,\\"type\\":\\"root\\",\\"children\\":[{\\"id\\":-2,\\"type\\":\\"__snapshot_d6fb6_test_1\\",\\"values\\":[\\"-2:0:\\",\\"-2:1:\\"],\\"children\\":[{\\"id\\":-3,\\"type\\":\\"wrapper\\",\\"children\\":[{\\"id\\":-4,\\"type\\":null,\\"values\\":[0]}]},{\\"id\\":-5,\\"type\\":\\"wrapper\\",\\"children\\":[{\\"id\\":-6,\\"type\\":\\"__snapshot_d6fb6_test_2\\"},{\\"id\\":-7,\\"type\\":\\"__snapshot_d6fb6_test_3\\"}]}]}]}","jsReadyEventIdSwap":{}}])", ], [ - "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_426db_test_3, __id: 7", + "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_d6fb6_test_2, __id: 6", ], [ - "[BackgroundThread Component Render] name: App, uniqID: __snapshot_426db_test_1, __id: 2", + "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_d6fb6_test_3, __id: 7", ], [ - "[BackgroundThread Component Render] name: Fragment, uniqID: __snapshot_426db_test_1, __id: 2", + "[BackgroundThread Component Render] name: App, uniqID: __snapshot_d6fb6_test_1, __id: 2", + ], + [ + "[BackgroundThread Component Render] name: Fragment, uniqID: __snapshot_d6fb6_test_1, __id: 2", ], [ "[ReactLynxDebug] MTS -> BTS OnLifecycleEvent: @@ -244,7 +467,7 @@ describe('alog', () => { "children": [ { "id": -2, - "type": "__snapshot_426db_test_1", + "type": "__snapshot_d6fb6_test_1", "values": [ "-2:0:", "-2:1:" @@ -269,11 +492,11 @@ describe('alog', () => { "children": [ { "id": -6, - "type": "__snapshot_426db_test_2" + "type": "__snapshot_d6fb6_test_2" }, { "id": -7, - "type": "__snapshot_426db_test_3" + "type": "__snapshot_d6fb6_test_3" } ] } @@ -287,39 +510,66 @@ describe('alog', () => { [ "[ReactLynxDebug] SnapshotInstance tree for first screen hydration: | -1(root): undefined - | -2(__snapshot_426db_test_1): ["-2:0:","-2:1:"] + | -2(__snapshot_d6fb6_test_1): ["-2:0:","-2:1:"] | -3(wrapper): undefined | -4(null): [0] | -5(wrapper): undefined - | -6(__snapshot_426db_test_2): undefined - | -7(__snapshot_426db_test_3): undefined", + | -6(__snapshot_d6fb6_test_2): undefined + | -7(__snapshot_d6fb6_test_3): undefined", ], [ "[ReactLynxDebug] BackgroundSnapshotInstance tree before hydration: | 1(root): undefined - | 2(__snapshot_426db_test_1): [null,null] + | 2(__snapshot_d6fb6_test_1): [null,null] | 3(wrapper): undefined | 4(null): [0] | 5(wrapper): undefined - | 6(__snapshot_426db_test_2): undefined - | 7(__snapshot_426db_test_3): undefined", + | 6(__snapshot_d6fb6_test_2): undefined + | 7(__snapshot_d6fb6_test_3): undefined", ], [ "[ReactLynxDebug] BackgroundSnapshotInstance after hydration: | -1(root): undefined - | -2(__snapshot_426db_test_1): [null,null] + | -2(__snapshot_d6fb6_test_1): [null,null] | -3(wrapper): undefined | -4(null): [0] | -5(wrapper): undefined - | -6(__snapshot_426db_test_2): undefined - | -7(__snapshot_426db_test_3): undefined", + | -6(__snapshot_d6fb6_test_2): undefined + | -7(__snapshot_d6fb6_test_3): undefined", + ], + [ + "[ReactLynxDebug] BTS -> MTS updateMainThread: + { + "data": { + "patchList": [ + { + "snapshotPatch": [], + "id": 2 + } + ] + }, + "patchOptions": { + "isHydration": true, + "reloadVersion": 0, + "pipelineOptions": { + "pipelineID": "pipelineID", + "needTimestamps": true, + "pipelineOrigin": "reactLynxHydrate", + "dsl": "reactLynx", + "stage": "hydrate" + } + } + }", + ], + [ + "[ReactLynxDebug] FiberElement API call #33: __FlushElementTree(PAGE#0, {"pipelineOptions":{"pipelineID":"pipelineID","needTimestamps":true,"pipelineOrigin":"reactLynxHydrate","dsl":"reactLynx","stage":"hydrate"}})", ], [ "[ReactLynxDebug] BTS received event: { "handlerName": "-2:0:", "type": "bindEvent:tap", - "snapshotType": "__snapshot_426db_test_1", + "snapshotType": "__snapshot_d6fb6_test_1", "jsFunctionName": "" }", ], @@ -328,18 +578,54 @@ describe('alog', () => { { "handlerName": "-2:1:", "type": "catchEvent:focus", - "snapshotType": "__snapshot_426db_test_1", + "snapshotType": "__snapshot_d6fb6_test_1", "jsFunctionName": "handleFocus" }", ], [ - "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_426db_test_2, __id: -6", + "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_d6fb6_test_2, __id: -6", + ], + [ + "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_d6fb6_test_3, __id: -7", + ], + [ + "[BackgroundThread Component Render] name: App, uniqID: __snapshot_d6fb6_test_1, __id: -2", + ], + [ + "[ReactLynxDebug] BTS -> MTS updateMainThread: + { + "data": { + "patchList": [ + { + "id": 3, + "snapshotPatch": [ + { + "op": "SetAttribute", + "id": -4, + "dynamicPartIndex": 0, + "value": 1 + } + ] + } + ] + }, + "patchOptions": { + "reloadVersion": 0, + "pipelineOptions": { + "pipelineID": "pipelineID", + "needTimestamps": true, + "pipelineOrigin": "reactLynxHydrate", + "dsl": "reactLynx", + "stage": "hydrate" + } + } + }", ], [ - "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_426db_test_3, __id: -7", + "[ReactLynxDebug] FiberElement API call #34: __SetAttribute(#text#7, "text", 1)", ], [ - "[BackgroundThread Component Render] name: App, uniqID: __snapshot_426db_test_1, __id: -2", + "[ReactLynxDebug] FiberElement API call #35: __FlushElementTree(PAGE#0, {"pipelineOptions":{"pipelineID":"pipelineID","needTimestamps":true,"pipelineOrigin":"reactLynxHydrate","dsl":"reactLynx","stage":"hydrate"}})", ], ] `); @@ -353,6 +639,15 @@ describe('alog', () => { expect(lynxTestingEnv.mainThread.console.alog.mock.calls).toMatchInlineSnapshot(` [ + [ + "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_d6fb6_test_2, __id: -6", + ], + [ + "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_d6fb6_test_3, __id: -7", + ], + [ + "[BackgroundThread Component Render] name: App, uniqID: __snapshot_d6fb6_test_1, __id: -2", + ], [ "[ReactLynxDebug] BTS -> MTS updateMainThread: { @@ -394,13 +689,49 @@ describe('alog', () => { expect(lynxTestingEnv.backgroundThread.console.alog.mock.calls).toMatchInlineSnapshot(` [ [ - "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_426db_test_2, __id: -6", + "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_d6fb6_test_2, __id: -6", + ], + [ + "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_d6fb6_test_3, __id: -7", ], [ - "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_426db_test_3, __id: -7", + "[BackgroundThread Component Render] name: App, uniqID: __snapshot_d6fb6_test_1, __id: -2", + ], + [ + "[ReactLynxDebug] BTS -> MTS updateMainThread: + { + "data": { + "patchList": [ + { + "id": 4, + "snapshotPatch": [ + { + "op": "SetAttribute", + "id": -4, + "dynamicPartIndex": 0, + "value": 0 + } + ] + } + ] + }, + "patchOptions": { + "reloadVersion": 0, + "pipelineOptions": { + "pipelineID": "pipelineID", + "needTimestamps": true, + "pipelineOrigin": "reactLynxHydrate", + "dsl": "reactLynx", + "stage": "hydrate" + } + } + }", ], [ - "[BackgroundThread Component Render] name: App, uniqID: __snapshot_426db_test_1, __id: -2", + "[ReactLynxDebug] FiberElement API call #36: __SetAttribute(#text#7, "text", 0)", + ], + [ + "[ReactLynxDebug] FiberElement API call #37: __FlushElementTree(PAGE#0, {"pipelineOptions":{"pipelineID":"pipelineID","needTimestamps":true,"pipelineOrigin":"reactLynxHydrate","dsl":"reactLynx","stage":"hydrate"}})", ], ] `); @@ -414,6 +745,15 @@ describe('alog', () => { expect(lynxTestingEnv.mainThread.console.alog.mock.calls).toMatchInlineSnapshot(` [ + [ + "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_d6fb6_test_2, __id: -6", + ], + [ + "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_d6fb6_test_3, __id: -7", + ], + [ + "[BackgroundThread Component Render] name: App, uniqID: __snapshot_d6fb6_test_1, __id: -2", + ], [ "[ReactLynxDebug] BTS -> MTS updateMainThread: { @@ -455,13 +795,49 @@ describe('alog', () => { expect(lynxTestingEnv.backgroundThread.console.alog.mock.calls).toMatchInlineSnapshot(` [ [ - "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_426db_test_2, __id: -6", + "[BackgroundThread Component Render] name: ClassComponent, uniqID: __snapshot_d6fb6_test_2, __id: -6", + ], + [ + "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_d6fb6_test_3, __id: -7", ], [ - "[BackgroundThread Component Render] name: FunctionComponent, uniqID: __snapshot_426db_test_3, __id: -7", + "[BackgroundThread Component Render] name: App, uniqID: __snapshot_d6fb6_test_1, __id: -2", ], [ - "[BackgroundThread Component Render] name: App, uniqID: __snapshot_426db_test_1, __id: -2", + "[ReactLynxDebug] BTS -> MTS updateMainThread: + { + "data": { + "patchList": [ + { + "id": 5, + "snapshotPatch": [ + { + "op": "SetAttribute", + "id": -4, + "dynamicPartIndex": 0, + "value": 1 + } + ] + } + ] + }, + "patchOptions": { + "reloadVersion": 0, + "pipelineOptions": { + "pipelineID": "pipelineID", + "needTimestamps": true, + "pipelineOrigin": "reactLynxHydrate", + "dsl": "reactLynx", + "stage": "hydrate" + } + } + }", + ], + [ + "[ReactLynxDebug] FiberElement API call #38: __SetAttribute(#text#7, "text", 1)", + ], + [ + "[ReactLynxDebug] FiberElement API call #39: __FlushElementTree(PAGE#0, {"pipelineOptions":{"pipelineID":"pipelineID","needTimestamps":true,"pipelineOrigin":"reactLynxHydrate","dsl":"reactLynx","stage":"hydrate"}})", ], ] `); diff --git a/packages/react/testing-library/src/__tests__/auto-cleanup-skip.test.jsx b/packages/react/testing-library/src/__tests__/auto-cleanup-skip.test.jsx index ac369899e4..d729637359 100644 --- a/packages/react/testing-library/src/__tests__/auto-cleanup-skip.test.jsx +++ b/packages/react/testing-library/src/__tests__/auto-cleanup-skip.test.jsx @@ -1,3 +1,5 @@ +import { beforeAll, test, expect } from 'vitest'; + let render; beforeAll(async () => { diff --git a/packages/react/testing-library/src/__tests__/cleanup.test.jsx b/packages/react/testing-library/src/__tests__/cleanup.test.jsx index 00bd089395..ffbebe04ed 100644 --- a/packages/react/testing-library/src/__tests__/cleanup.test.jsx +++ b/packages/react/testing-library/src/__tests__/cleanup.test.jsx @@ -1,4 +1,4 @@ -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import { render, cleanup } from '..'; import { Component } from '@lynx-js/react'; diff --git a/packages/react/testing-library/src/__tests__/css/index.test.jsx b/packages/react/testing-library/src/__tests__/css/index.test.jsx index e7e7c7c99b..86d10c66fe 100644 --- a/packages/react/testing-library/src/__tests__/css/index.test.jsx +++ b/packages/react/testing-library/src/__tests__/css/index.test.jsx @@ -34,8 +34,8 @@ describe('CSS', () => { `); }); it('should render a component with CSS module styles object', () => { - // Assert stable shape (prefix) rather than exact hash - expect(style3.baz).toMatch(/^_baz_[a-z0-9]+$/); + // to be a string + expect(style3.baz).toBeTypeOf('string'); const TestComponent = () => Hello World; const { container } = render(); diff --git a/packages/react/testing-library/src/__tests__/end-to-end.test.jsx b/packages/react/testing-library/src/__tests__/end-to-end.test.jsx index 1308f62876..dd263864a1 100644 --- a/packages/react/testing-library/src/__tests__/end-to-end.test.jsx +++ b/packages/react/testing-library/src/__tests__/end-to-end.test.jsx @@ -1,6 +1,6 @@ import '@testing-library/jest-dom'; import { Component } from 'preact'; -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import { render, screen, waitForElementToBeRemoved } from '..'; import { snapshotInstanceManager } from '../../../runtime/lib/snapshot/index.js'; @@ -67,7 +67,7 @@ test('state change will cause re-render', async () => { "children": undefined, "extraProps": undefined, "id": 2, - "type": "__snapshot_354a3_test_1", + "type": "__snapshot_f46c5_test_1", "values": undefined, }, ], @@ -80,7 +80,7 @@ test('state change will cause re-render', async () => { "children": undefined, "extraProps": undefined, "id": 2, - "type": "__snapshot_354a3_test_1", + "type": "__snapshot_f46c5_test_1", "values": undefined, }, } @@ -100,7 +100,7 @@ test('state change will cause re-render', async () => { [ "rLynxChange", { - "data": "{"patchList":[{"snapshotPatch":[0,"__snapshot_354a3_test_1",2,1,-1,2,null],"id":2}]}", + "data": "{"patchList":[{"snapshotPatch":[0,"__snapshot_f46c5_test_1",2,1,-1,2,null],"id":2}]}", "patchOptions": { "isHydration": true, "pipelineOptions": { @@ -118,7 +118,7 @@ test('state change will cause re-render', async () => { [ "rLynxChange", { - "data": "{"patchList":[{"id":3,"snapshotPatch":[2,-1,2,0,"__snapshot_354a3_test_2",3,0,null,4,3,4,0,"Hello World",1,3,4,null,1,-1,3,null]}]}", + "data": "{"patchList":[{"id":3,"snapshotPatch":[2,-1,2,0,"__snapshot_f46c5_test_2",3,0,null,4,3,4,0,"Hello World",1,3,4,null,1,-1,3,null]}]}", "patchOptions": { "pipelineOptions": { "dsl": "reactLynx", diff --git a/packages/react/testing-library/src/__tests__/lazy-bundle/index.test.jsx b/packages/react/testing-library/src/__tests__/lazy-bundle/index.test.jsx index 9e23c0a39b..3e7e8159a4 100644 --- a/packages/react/testing-library/src/__tests__/lazy-bundle/index.test.jsx +++ b/packages/react/testing-library/src/__tests__/lazy-bundle/index.test.jsx @@ -22,7 +22,7 @@ function LazyComponentLoader({ url }) { return ( loading...}> - + {process.env.RSTEST ? null : } ); } @@ -55,7 +55,18 @@ describe('lazy bundle', () => { timeout: 50_000, }); - expect(container.firstChild).toMatchInlineSnapshot(` + if (process.env.RSTEST) { + expect(container.firstChild).toMatchInlineSnapshot(` + + + + Hello from LazyComponent + + + + `); + } else { + expect(container.firstChild).toMatchInlineSnapshot(` @@ -67,6 +78,7 @@ describe('lazy bundle', () => { `); + } }); }); @@ -166,12 +178,12 @@ describe('Suspense', () => { { "id": 2, "op": "CreateElement", - "type": "__snapshot_fffe1_test_3", + "type": "__snapshot_50869_test_3", }, { "id": 7, "op": "CreateElement", - "type": "__snapshot_fffe1_test_4", + "type": "__snapshot_50869_test_4", }, { "beforeId": null, @@ -193,7 +205,7 @@ describe('Suspense', () => { { "id": 2, "op": "CreateElement", - "type": "__snapshot_fffe1_test_3", + "type": "__snapshot_50869_test_3", }, { "id": 8, @@ -203,7 +215,7 @@ describe('Suspense', () => { { "id": 9, "op": "CreateElement", - "type": "__snapshot_fffe1_test_4", + "type": "__snapshot_50869_test_4", }, { "beforeId": null, @@ -294,51 +306,54 @@ describe('Suspense', () => { if (name === 'PreactSuspense') { // is torn down, (it is triggered in first render but delayed 10_000ms to execute, we use `vi.runAllTimers()` to simulate the situation that will cause the bug) // loading... is torn down - expect(tearDownInstances).toMatchInlineSnapshot(` - [ - { - "__id": 3, - "create": "function() { - const pageId = __vite_ssr_import_1__.__pageId; - const el = __CreateView(pageId); - __SetClasses(el, "lazy-wrapper"); - const el1 = __CreateWrapperElement(pageId); - __AppendElement(el, el1); - const el2 = __CreateText(pageId); - __AppendElement(el, el2); - const el3 = __CreateRawText("Hello, ReactLynx"); - __AppendElement(el2, el3); - const el4 = __CreateWrapperElement(pageId); - __AppendElement(el, el4); - return [ - el, - el1, - el2, - el3, - el4 - ]; - }", - "type": "__snapshot_fffe1_test_5", - }, - { - "__id": 7, - "create": "function() { - const pageId = __vite_ssr_import_1__.__pageId; - const el = __CreateText(pageId); - __SetClasses(el, "loading"); - const el1 = __CreateRawText("loading..."); - __AppendElement(el, el1); - return [ - el, - el1 - ]; - }", - "type": "__snapshot_fffe1_test_4", - }, - ] - `); + if (!process.env.RSTEST) { + expect(tearDownInstances).toMatchInlineSnapshot(` + [ + { + "__id": 3, + "create": "function() { + const pageId = __vite_ssr_import_1__.__pageId; + const el = __CreateView(pageId); + __SetClasses(el, "lazy-wrapper"); + const el1 = __CreateWrapperElement(pageId); + __AppendElement(el, el1); + const el2 = __CreateText(pageId); + __AppendElement(el, el2); + const el3 = __CreateRawText("Hello, ReactLynx"); + __AppendElement(el2, el3); + const el4 = __CreateWrapperElement(pageId); + __AppendElement(el, el4); + return [ + el, + el1, + el2, + el3, + el4 + ]; + }", + "type": "__snapshot_50869_test_5", + }, + { + "__id": 7, + "create": "function() { + const pageId = __vite_ssr_import_1__.__pageId; + const el = __CreateText(pageId); + __SetClasses(el, "loading"); + const el1 = __CreateRawText("loading..."); + __AppendElement(el, el1); + return [ + el, + el1 + ]; + }", + "type": "__snapshot_50869_test_4", + }, + ] + `); + } } else { - expect(tearDownInstances).toMatchInlineSnapshot(` + if (!process.env.RSTEST) { + expect(tearDownInstances).toMatchInlineSnapshot(` [ { "__id": 8, @@ -352,6 +367,7 @@ describe('Suspense', () => { }, ] `); + } } act(() => { diff --git a/packages/react/testing-library/src/__tests__/list.test.jsx b/packages/react/testing-library/src/__tests__/list.test.jsx index cb718f18ef..f8b3bfe6cf 100644 --- a/packages/react/testing-library/src/__tests__/list.test.jsx +++ b/packages/react/testing-library/src/__tests__/list.test.jsx @@ -3,7 +3,7 @@ // LICENSE file in the root directory of this source tree. import { act } from 'preact/test-utils'; -import { describe, expect } from 'vitest'; +import { describe, expect, vi } from 'vitest'; import { Component, useState } from '@lynx-js/react'; @@ -31,7 +31,7 @@ describe('list', () => { expect(container).toMatchInlineSnapshot(` `); @@ -42,7 +42,7 @@ describe('list', () => { expect(container).toMatchInlineSnapshot(` { expect(container).toMatchInlineSnapshot(` { expect(container).toMatchInlineSnapshot(` { expect(container).toMatchInlineSnapshot(` { > @@ -359,43 +359,128 @@ describe('list', () => { ] `); expect(__FlushElementTree).toHaveBeenCalledTimes(1); - expect(__FlushElementTree.mock.calls).toMatchInlineSnapshot(` - [ - [ - - - - - 1 - - - 1 - - - - - hello - - - - , - { - "elementID": 33, - "listID": 2, - "operationID": undefined, - "triggerLayout": true, - }, - ], - ] + const [[flushedElement, flushInfo]] = __FlushElementTree.mock.calls; + expect(flushedElement).toMatchInlineSnapshot(` + + + + + 1 + + + 1 + + + + + hello + + + + + `); + expect(flushInfo).toMatchObject({ + listID: 2, + operationID: undefined, + triggerLayout: true, + }); + expect(flushInfo.elementID).toBeTypeOf('number'); + + expect(list).toMatchInlineSnapshot(` + + + + + + 4 + + + 4 + + + + + hello + + + + + + + + + 5 + + + 5 + + + + + hello + + + + + + + + + 2 + + + 2 + + + + + hello + + + + + + + + + 1 + + + 1 + + + + + hello + + + + + `); expect(list).toMatchInlineSnapshot(` should render as normal', () => { `); @@ -585,7 +670,7 @@ describe('list - deferred should render as normal', () => { `); @@ -597,7 +682,7 @@ describe('list - deferred should render as normal', () => { should render as normal', () => { `); @@ -766,7 +851,7 @@ describe('list - deferred should render as normal', () => { `); @@ -786,7 +871,7 @@ describe('list - deferred should render as normal', () => { `); @@ -805,7 +890,7 @@ describe('list - deferred should render as normal', () => { should render as normal', () => { "rLynxFirstScreen", { "jsReadyEventIdSwap": {}, - "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__snapshot_a9e46_test_30","children":[{"id":-3,"type":"__snapshot_a9e46_test_31","values":[{"item-key":0}],"children":[{"id":-4,"type":"__snapshot_a9e46_test_29","values":[{"style":{"backgroundColor":"red","margin":"12px"}}],"children":[{"id":-5,"type":"__snapshot_a9e46_test_32","children":[{"id":-6,"type":null,"values":[0]}]}]}]},{"id":-7,"type":"__snapshot_a9e46_test_31","values":[{"item-key":1}],"children":[{"id":-8,"type":"__snapshot_a9e46_test_29","values":[{"style":{"backgroundColor":"red","margin":"12px"}}],"children":[{"id":-9,"type":"__snapshot_a9e46_test_32","children":[{"id":-10,"type":null,"values":[1]}]}]}]},{"id":-11,"type":"__snapshot_a9e46_test_31","values":[{"item-key":2}],"children":[{"id":-12,"type":"__snapshot_a9e46_test_29","values":[{"style":{"backgroundColor":"red","margin":"12px"}}],"children":[{"id":-13,"type":"__snapshot_a9e46_test_32","children":[{"id":-14,"type":null,"values":[2]}]}]}]}]}]}", + "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__snapshot_d0c07_test_30","children":[{"id":-3,"type":"__snapshot_d0c07_test_31","values":[{"item-key":0}],"children":[{"id":-4,"type":"__snapshot_d0c07_test_29","values":[{"style":{"backgroundColor":"red","margin":"12px"}}],"children":[{"id":-5,"type":"__snapshot_d0c07_test_32","children":[{"id":-6,"type":null,"values":[0]}]}]}]},{"id":-7,"type":"__snapshot_d0c07_test_31","values":[{"item-key":1}],"children":[{"id":-8,"type":"__snapshot_d0c07_test_29","values":[{"style":{"backgroundColor":"red","margin":"12px"}}],"children":[{"id":-9,"type":"__snapshot_d0c07_test_32","children":[{"id":-10,"type":null,"values":[1]}]}]}]},{"id":-11,"type":"__snapshot_d0c07_test_31","values":[{"item-key":2}],"children":[{"id":-12,"type":"__snapshot_d0c07_test_29","values":[{"style":{"backgroundColor":"red","margin":"12px"}}],"children":[{"id":-13,"type":"__snapshot_d0c07_test_32","children":[{"id":-14,"type":null,"values":[2]}]}]}]}]}]}", }, ], ], diff --git a/packages/react/testing-library/src/__tests__/lynx.test.jsx b/packages/react/testing-library/src/__tests__/lynx.test.jsx index 6151c8f730..392a35bb88 100644 --- a/packages/react/testing-library/src/__tests__/lynx.test.jsx +++ b/packages/react/testing-library/src/__tests__/lynx.test.jsx @@ -62,7 +62,7 @@ describe('lynx global API', () => { { "id": 2, "op": "CreateElement", - "type": "__snapshot_d4b6f_test_1", + "type": "__snapshot_034cb_test_1", }, { "beforeId": null, diff --git a/packages/react/testing-library/src/__tests__/rerender.test.jsx b/packages/react/testing-library/src/__tests__/rerender.test.jsx index 559e632fc6..df6a9a9bde 100644 --- a/packages/react/testing-library/src/__tests__/rerender.test.jsx +++ b/packages/react/testing-library/src/__tests__/rerender.test.jsx @@ -1,6 +1,6 @@ import '@testing-library/jest-dom'; import { render } from '..'; -import { expect } from 'vitest'; +import { expect, vi } from 'vitest'; import { useEffect, useState } from '@lynx-js/react'; test('rerender will re-render the element', async () => { diff --git a/packages/react/testing-library/src/__tests__/worklet.test.jsx b/packages/react/testing-library/src/__tests__/worklet.test.jsx index d1d50ac624..4caf0b61c0 100644 --- a/packages/react/testing-library/src/__tests__/worklet.test.jsx +++ b/packages/react/testing-library/src/__tests__/worklet.test.jsx @@ -77,7 +77,7 @@ describe('worklet', () => { [ "rLynxChange", { - "data": "{"patchList":[{"snapshotPatch":[3,-2,0,{"_wkltId":"a45f:test:2","_workletType":"main-thread","_execId":1}],"id":2}]}", + "data": "{"patchList":[{"snapshotPatch":[3,-2,0,{"_wkltId":"15ab:test:2","_workletType":"main-thread","_execId":1}],"id":2}]}", "patchOptions": { "isHydration": true, "pipelineOptions": { @@ -159,7 +159,7 @@ describe('worklet', () => { [ "rLynxChange", { - "data": "{"patchList":[{"snapshotPatch":[3,-2,1,{"_c":{"props":{"main-thread:onClick":{"_wkltId":"a45f:test:3"}}},"_wkltId":"a45f:test:4","_execId":1}],"id":2}]}", + "data": "{"patchList":[{"snapshotPatch":[3,-2,1,{"_c":{"props":{"main-thread:onClick":{"_wkltId":"15ab:test:3"}}},"_wkltId":"15ab:test:4","_execId":1}],"id":2}]}", "patchOptions": { "isHydration": true, "pipelineOptions": { @@ -249,7 +249,7 @@ describe('worklet', () => { [ "rLynxChange", { - "data": "{"patchList":[{"snapshotPatch":[3,-2,0,{"_c":{"props":{"main-thread:onScroll":{"_wkltId":"a45f:test:5"}}},"_wkltId":"a45f:test:6","_execId":1}],"id":2}]}", + "data": "{"patchList":[{"snapshotPatch":[3,-2,0,{"_c":{"props":{"main-thread:onScroll":{"_wkltId":"15ab:test:5"}}},"_wkltId":"15ab:test:6","_execId":1}],"id":2}]}", "patchOptions": { "isHydration": true, "pipelineOptions": { @@ -343,7 +343,7 @@ describe('worklet', () => { [ "rLynxChange", { - "data": "{"patchList":[{"snapshotPatch":[3,-2,0,{"_wkltId":"a45f:test:8","_jsFn":{"_jsFn1":{"_jsFnId":2,"_fn":"[BackgroundFunction]"}},"_execId":1}],"id":2}]}", + "data": "{"patchList":[{"snapshotPatch":[3,-2,0,{"_wkltId":"15ab:test:8","_jsFn":{"_jsFn1":{"_jsFnId":2,"_fn":"[BackgroundFunction]"}},"_execId":1}],"id":2}]}", "patchOptions": { "isHydration": true, "pipelineOptions": { @@ -447,7 +447,7 @@ describe('worklet', () => { [ "rLynxChange", { - "data": "{"patchList":[{"snapshotPatch":[3,-2,0,{"_wvid":1},3,-2,1,{"_c":{"ref":{"_wvid":1},"num":{"_wvid":2}},"_wkltId":"a45f:test:9","_execId":1}],"id":2}]}", + "data": "{"patchList":[{"snapshotPatch":[3,-2,0,{"_wvid":1},3,-2,1,{"_c":{"ref":{"_wvid":1},"num":{"_wvid":2}},"_wkltId":"15ab:test:9","_execId":1}],"id":2}]}", "patchOptions": { "isHydration": true, "pipelineOptions": { diff --git a/packages/react/testing-library/src/env/index.ts b/packages/react/testing-library/src/env/index.ts new file mode 100644 index 0000000000..cd62eb3d54 --- /dev/null +++ b/packages/react/testing-library/src/env/index.ts @@ -0,0 +1,2 @@ +export { LynxTestingEnv, installLynxTestingEnv, uninstallLynxTestingEnv } from '@lynx-js/testing-environment'; +export type { LynxEnv } from '@lynx-js/testing-environment'; diff --git a/packages/react/testing-library/src/env/rstest.ts b/packages/react/testing-library/src/env/rstest.ts new file mode 100644 index 0000000000..de16c1a402 --- /dev/null +++ b/packages/react/testing-library/src/env/rstest.ts @@ -0,0 +1,7 @@ +import { LynxTestingEnv } from './index.js'; + +global.lynxEnv = { + window, +}; +const lynxTestingEnv = new LynxTestingEnv(); +global.lynxTestingEnv = lynxTestingEnv; diff --git a/packages/react/testing-library/src/env/vitest.ts b/packages/react/testing-library/src/env/vitest.ts index cf89eb9fdb..c6ff6608f0 100644 --- a/packages/react/testing-library/src/env/vitest.ts +++ b/packages/react/testing-library/src/env/vitest.ts @@ -1,3 +1,27 @@ -import env from '@lynx-js/testing-environment/env/vitest'; +import { builtinEnvironments, type Environment } from 'vitest/environments'; +import { installLynxTestingEnv, uninstallLynxTestingEnv } from './index.js'; + +const env: Environment = { + name: 'lynxTestingEnv', + transformMode: 'web', + async setup(global) { + const fakeGlobal: { + jsdom?: any; + } = {}; + const jsdomEnvironment = await builtinEnvironments.jsdom.setup( + fakeGlobal, + {}, + ); + + installLynxTestingEnv(global, fakeGlobal.jsdom); + + return { + async teardown(global) { + await jsdomEnvironment.teardown(fakeGlobal); + uninstallLynxTestingEnv(global); + }, + }; + }, +}; export default env; diff --git a/packages/react/testing-library/src/plugins/index.ts b/packages/react/testing-library/src/plugins/index.ts new file mode 100644 index 0000000000..2b150f8d92 --- /dev/null +++ b/packages/react/testing-library/src/plugins/index.ts @@ -0,0 +1,2 @@ +export { testingLibraryPlugin as vitestTestingLibraryPlugin } from './vitest.js'; +export type { TestingLibraryOptions } from './vitest.js'; diff --git a/packages/react/testing-library/src/vitest.config.js b/packages/react/testing-library/src/plugins/vitest.ts similarity index 60% rename from packages/react/testing-library/src/vitest.config.js rename to packages/react/testing-library/src/plugins/vitest.ts index 7c37d9dda5..bf4ae89204 100644 --- a/packages/react/testing-library/src/vitest.config.js +++ b/packages/react/testing-library/src/plugins/vitest.ts @@ -1,62 +1,69 @@ -import { defineConfig } from 'vitest/config'; +// 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 { ResolvedConfig, Vite } from 'vitest/node'; import { VitestPackageInstaller } from 'vitest/node'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { createRequire } from 'module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const require = createRequire(import.meta.url); -async function ensurePackagesInstalled() { - const installer = new VitestPackageInstaller(); - const installed = await installer.ensureInstalled( - 'jsdom', - process.cwd(), - ); - if (!installed) { - console.log('ReactLynx Testing Library requires jsdom to be installed.'); - process.exit(1); - } -} +export interface TestingLibraryOptions { + /** + * The package name of the ReactLynx runtime package. + * + * @default `@lynx-js/react` + */ + runtimePkgName?: string; + + /** + * The engine version to use for the transform. + * + * @default `''` + */ + engineVersion?: string; -/** - * @returns {import('vitest/config').ViteUserConfig} - */ -export const createVitestConfig = async (options) => { - await ensurePackagesInstalled(); + /** + * Enable experimental React Compiler support. + * + * Requires `@babel/core`, `babel-plugin-react-compiler`, + * `@babel/plugin-syntax-jsx`, and `@babel/plugin-syntax-typescript` + * to be installed in your project. + * + * @default `false` + */ + experimental_enableReactCompiler?: boolean; +} +export function testingLibraryPlugin( + options?: TestingLibraryOptions, +): Vite.PluginOption { const runtimeOSSPkgName = '@lynx-js/react'; const runtimePkgName = options?.runtimePkgName ?? runtimeOSSPkgName; - const runtimeDir = path.dirname(require.resolve(`${runtimePkgName}/package.json`)); + const runtimeDir = path.dirname( + require.resolve(`${runtimePkgName}/package.json`), + ); const runtimeOSSDir = path.dirname( require.resolve(`${runtimeOSSPkgName}/package.json`, { paths: [runtimeDir, __dirname], }), ); + const preactDir = path.dirname( require.resolve('preact/package.json', { paths: [runtimeOSSDir], }), ); - const generateAlias = (pkgName, pkgDir, resolveDir) => { - const pkgExports = require(path.join(pkgDir, 'package.json')).exports; - const pkgAlias = []; - Object.keys(pkgExports).forEach((key) => { - const name = path.posix.join(pkgName, key); - pkgAlias.push({ - find: new RegExp('^' + name + '$'), - replacement: require.resolve(name, { - paths: [resolveDir, __dirname], - }), - }); - }); - return pkgAlias; - }; - - const runtimeOSSAlias = generateAlias(runtimeOSSPkgName, runtimeOSSDir, runtimeDir); - let runtimeAlias = []; + const runtimeOSSAlias = generateAlias( + runtimeOSSPkgName, + runtimeOSSDir, + runtimeDir, + ); + let runtimeAlias: Vite.Alias[] = []; if (runtimePkgName !== runtimeOSSPkgName) { runtimeAlias = generateAlias(runtimePkgName, runtimeDir, __dirname); } @@ -85,11 +92,11 @@ export const createVitestConfig = async (options) => { }, ]; - function transformReactCompilerPlugin() { - let rootContext, compilerDeps, babel; + function transformReactCompilerPlugin(): Vite.Plugin { + let rootContext: string, compilerDeps: ReturnType, babel: any; - function resolveCompilerDeps(rootContext) { - const missingBabelPackages = []; + function resolveCompilerDeps(rootContext: string) { + const missingBabelPackages: string[] = []; const [ babelPath, babelPluginReactCompilerPath, @@ -132,9 +139,9 @@ export const createVitestConfig = async (options) => { name: 'transformReactCompilerPlugin', enforce: 'pre', config(config) { - rootContext = config.root; + rootContext = config.root!; - const reactCompilerRuntimeAlias = []; + const reactCompilerRuntimeAlias: Vite.Alias[] = []; try { reactCompilerRuntimeAlias.push( { @@ -150,14 +157,30 @@ export const createVitestConfig = async (options) => { }, ); } catch (e) { - // console.log('react-compiler-runtime not found, skip alias'); + // react-compiler-runtime not found, skip alias } - config.test.alias.push(...reactCompilerRuntimeAlias); + let mergedAlias: Vite.Alias[] = [...reactCompilerRuntimeAlias]; + if (config.test?.alias) { + if (Array.isArray(config.test.alias)) { + mergedAlias = [...config.test.alias, ...mergedAlias]; + } else { + mergedAlias = [ + ...Object.entries(config.test.alias).map(([key, value]) => ({ + find: key, + replacement: value, + })), + ...mergedAlias, + ]; + } + } + + config.test = config.test || {}; + config.test.alias = mergedAlias; compilerDeps = resolveCompilerDeps(rootContext); const { babelPath } = compilerDeps; - babel = require(babelPath); + babel = require(babelPath!); }, async transform(sourceText, sourcePath) { if (/\.(?:jsx|tsx)$/.test(sourcePath)) { @@ -192,7 +215,7 @@ export const createVitestConfig = async (options) => { ); } } catch (e) { - this.error(e); + this.error(e as Error); } } @@ -201,10 +224,15 @@ export const createVitestConfig = async (options) => { }; } - function transformReactLynxPlugin() { + let config: ResolvedConfig; + + function transformReactLynxPlugin(): Vite.Plugin { return { name: 'transformReactLynxPlugin', enforce: 'pre', + async buildStart() { + await ensurePackagesInstalled(); + }, transform(sourceText, sourcePath) { const id = sourcePath; // Only transform JS files @@ -214,12 +242,11 @@ export const createVitestConfig = async (options) => { const { transformReactLynxSync } = require( '@lynx-js/react/transform', - ); + ) as typeof import('@lynx-js/react/transform'); // relativePath should be stable between different runs with different cwd - const relativePath = normalizeSlashes(path.relative( - __dirname, - sourcePath, - )); + const relativePath = normalizeSlashes( + path.relative(config.root, sourcePath), + ); const basename = path.basename(sourcePath); const result = transformReactLynxSync(sourceText, { mode: 'test', @@ -252,60 +279,88 @@ export const createVitestConfig = async (options) => { refresh: false, cssScope: false, }); + if (result.errors.length > 0) { // https://rollupjs.org/plugin-development/#this-error - result.errors.forEach(error => { - this.error( - error.text, - error.location, - ); + result.errors.forEach((error) => { + this.error(error.text ?? 'Unknown error', { + line: 1, + column: 1, + ...error.location, + }); }); } if (result.warnings.length > 0) { - result.warnings.forEach(warning => { - this.warn( - warning.text, - warning.location, - ); + result.warnings.forEach((warning) => { + this.warn(warning.text ?? 'Unknown warning', { + line: 1, + column: 1, + ...warning.location, + }); }); } return { code: result.code, - map: result.map, + map: result.map!, }; }, + config: () => ({ + test: { + environment: require.resolve( + `${runtimeOSSDir}/testing-library/dist/env/vitest`, + ), + globals: true, + setupFiles: [ + require.resolve('../setupFiles/vitest'), + ], + alias: [...runtimeOSSAlias, ...runtimeAlias, ...preactAlias, ...reactAlias], + }, + }), + configResolved(_config) { + // @ts-ignore + config = _config; + }, }; } - return defineConfig({ - server: { - fs: { - allow: [ - path.join(__dirname, '..'), - ], - }, - }, - plugins: [ - ...(options?.experimental_enableReactCompiler - ? [ - transformReactCompilerPlugin(), - ] - : []), - transformReactLynxPlugin(), - ], - test: { - environment: require.resolve( - './env/vitest', - ), - globals: true, - setupFiles: [path.join(__dirname, 'vitest-global-setup')], - alias: [...runtimeOSSAlias, ...runtimeAlias, ...preactAlias, ...reactAlias], - include: options?.include ?? ['src/**/*.test.{js,jsx,ts,tsx}'], - }, + return [ + ...(options?.experimental_enableReactCompiler + ? [transformReactCompilerPlugin()] + : []), + transformReactLynxPlugin(), + ]; +} + +async function ensurePackagesInstalled() { + const installer = new VitestPackageInstaller(); + const installed = await installer.ensureInstalled('jsdom', process.cwd()); + if (!installed) { + console.log('ReactLynx Testing Library requires jsdom to be installed.'); + process.exit(1); + } +} + +function generateAlias(pkgName: string, pkgDir: string, resolveDir: string) { + const pkgExports = require(path.join(pkgDir, 'package.json')).exports; + if (!pkgExports || typeof pkgExports !== 'object') { + return []; + } + const pkgAlias: Vite.Alias[] = []; + Object.keys(pkgExports).forEach((key) => { + const name = path.posix.join(pkgName, key); + // Escape special regex characters in the package name + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + pkgAlias.push({ + find: new RegExp('^' + escapedName + '$'), + replacement: require.resolve(name, { + paths: [resolveDir, __dirname], + }), + }); }); -}; + return pkgAlias; +} -function normalizeSlashes(file) { +function normalizeSlashes(file: string) { return file.replaceAll(path.win32.sep, '/'); } diff --git a/packages/react/testing-library/src/pure.jsx b/packages/react/testing-library/src/pure.jsx index 4eaa962281..5eef77f076 100644 --- a/packages/react/testing-library/src/pure.jsx +++ b/packages/react/testing-library/src/pure.jsx @@ -91,7 +91,7 @@ export function render( }); }, asFragment: () => { - const { document } = lynxTestingEnv.jsdom.window; + const { document } = lynxTestingEnv.env.window; const container = lynxTestingEnv.mainThread.elementTree.root; if (typeof document.createRange === 'function') { return document @@ -118,7 +118,7 @@ export function cleanup() { lynxTestingEnv.mainThread.elementTree.root = undefined; clearPage(); - lynxTestingEnv.jsdom.window.document.body.innerHTML = ''; + lynxTestingEnv.env.window.document.body.innerHTML = ''; if (isMainThread) { globalThis.lynxTestingEnv.switchToMainThread(); diff --git a/packages/react/testing-library/src/rstest-config.ts b/packages/react/testing-library/src/rstest-config.ts new file mode 100644 index 0000000000..dc7a053458 --- /dev/null +++ b/packages/react/testing-library/src/rstest-config.ts @@ -0,0 +1,113 @@ +import type { ExtendConfig, ExtendConfigFn } from '@rstest/core'; +import { createRequire } from 'node:module'; +import type { RsbuildConfig } from '@rsbuild/core'; + +export interface LynxConfigOptions { + /** + * The root path of the project. + * + * @default `process.cwd()` + */ + rootPath?: string; + + /** + * The path to the Lynx config file. + * + * @default `lynx.config.ts` + */ + configPath?: string; +} + +export interface RstestConfigOptions { + /** + * Customize the generated rstest config. + */ + modifyRstestConfig?: (config: ExtendConfig) => ExtendConfig | Promise; +} + +export interface LynxRstestConfigOptions extends LynxConfigOptions, RstestConfigOptions {} + +const require = createRequire(import.meta.url); + +function createDefaultRstestConfig(): ExtendConfig { + return { + testEnvironment: 'jsdom', + setupFiles: [require.resolve('./setupFiles/rstest')], + globals: true, + }; +} + +function normalizeSetupFiles( + setupFiles: ExtendConfig['setupFiles'], +): string[] { + if (!setupFiles) { + return []; + } + + return Array.isArray(setupFiles) ? setupFiles : [setupFiles]; +} + +async function applyRstestConfigModifier( + config: ExtendConfig, + modifyRstestConfig?: (config: ExtendConfig) => ExtendConfig | Promise, +): Promise { + if (!modifyRstestConfig) { + return config; + } + + return await modifyRstestConfig(config); +} + +export function withDefaultConfig( + options?: RstestConfigOptions, +): ExtendConfigFn { + return async () => { + return await applyRstestConfigModifier( + createDefaultRstestConfig(), + options?.modifyRstestConfig, + ); + }; +} + +export function withLynxConfig( + options?: LynxRstestConfigOptions, +): ExtendConfigFn { + return async () => { + const { loadConfig } = await import('@lynx-js/rspeedy'); + const lynxConfig = await loadConfig({ + cwd: options?.rootPath, + configPath: options?.configPath, + }); + + const { toRstestConfig } = await import('@rstest/adapter-rsbuild'); + const rstestConfig = toRstestConfig({ + rsbuildConfig: lynxConfig.content as RsbuildConfig, + }); + const defaultConfig = createDefaultRstestConfig(); + const setupFiles = Array.from( + new Set([ + ...normalizeSetupFiles(rstestConfig.setupFiles), + ...normalizeSetupFiles(defaultConfig.setupFiles), + ]), + ); + + const mergedConfig: ExtendConfig = { + ...rstestConfig, + ...defaultConfig, + plugins: [ + ...(rstestConfig.plugins || []), + { + name: 'lynx-adapter:remove-useless-plugins', + remove: ['lynx:rsbuild:qrcode'], + setup: () => {}, + }, + ], + setupFiles, + }; + + return await applyRstestConfigModifier( + mergedConfig, + options?.modifyRstestConfig, + ); + }; +} diff --git a/packages/react/testing-library/src/setupFiles/common/bootstrap.js b/packages/react/testing-library/src/setupFiles/common/bootstrap.js new file mode 100644 index 0000000000..f85071e11f --- /dev/null +++ b/packages/react/testing-library/src/setupFiles/common/bootstrap.js @@ -0,0 +1,6 @@ +globalThis.onInjectMainThreadGlobals( + globalThis.lynxTestingEnv.mainThread.globalThis, +); +globalThis.onInjectBackgroundThreadGlobals( + globalThis.lynxTestingEnv.backgroundThread.globalThis, +); diff --git a/packages/react/testing-library/src/vitest-global-setup.js b/packages/react/testing-library/src/setupFiles/common/runtime-setup.js similarity index 63% rename from packages/react/testing-library/src/vitest-global-setup.js rename to packages/react/testing-library/src/setupFiles/common/runtime-setup.js index 7abf35e436..778435c1de 100644 --- a/packages/react/testing-library/src/vitest-global-setup.js +++ b/packages/react/testing-library/src/setupFiles/common/runtime-setup.js @@ -1,50 +1,24 @@ import { options } from 'preact'; -import { expect } from 'vitest'; - -import { clearCommitTaskId, replaceCommitHook } from '../../runtime/lib/lifecycle/patch/commit.js'; -import { deinitGlobalSnapshotPatch } from '../../runtime/lib/lifecycle/patch/snapshotPatch.js'; -import { injectUpdateMainThread } from '../../runtime/lib/lifecycle/patch/updateMainThread.js'; -import { injectUpdateMTRefInitValue } from '../../runtime/lib/worklet/ref/updateInitValue.js'; -import { injectCalledByNative } from '../../runtime/lib/lynx/calledByNative.js'; -import { flushDelayedLifecycleEvents, injectTt } from '../../runtime/lib/lynx/tt.js'; -import { initElementPAPICallAlog } from '../../runtime/lib/alog/elementPAPICall.js'; -import { addCtxNotFoundEventListener } from '../../runtime/lib/lifecycle/patch/error.js'; -import { setRoot } from '../../runtime/lib/root.js'; + +import { clearCommitTaskId, replaceCommitHook } from '../../../../runtime/lib/lifecycle/patch/commit.js'; +import { deinitGlobalSnapshotPatch } from '../../../../runtime/lib/lifecycle/patch/snapshotPatch.js'; +import { injectUpdateMainThread } from '../../../../runtime/lib/lifecycle/patch/updateMainThread.js'; +import { injectUpdateMTRefInitValue } from '../../../../runtime/lib/worklet/ref/updateInitValue.js'; +import { injectCalledByNative } from '../../../../runtime/lib/lynx/calledByNative.js'; +import { flushDelayedLifecycleEvents, injectTt } from '../../../../runtime/lib/lynx/tt.js'; +import { initElementPAPICallAlog } from '../../../../runtime/lib/alog/elementPAPICall.js'; +import { addCtxNotFoundEventListener } from '../../../../runtime/lib/lifecycle/patch/error.js'; +import { setRoot } from '../../../../runtime/lib/root.js'; import { SnapshotInstance, BackgroundSnapshotInstance, backgroundSnapshotInstanceManager, snapshotInstanceManager, -} from '../../runtime/lib/snapshot/index.js'; -import { destroyWorklet } from '../../runtime/lib/worklet/destroy.js'; -import { initApiEnv } from '../../runtime/lib/worklet-runtime/api/lynxApi.js'; -import { initEventListeners } from '../../runtime/lib/worklet-runtime/listeners.js'; -import { initWorklet } from '../../runtime/lib/worklet-runtime/workletRuntime.js'; - -expect.addSnapshotSerializer({ - test(val) { - return Boolean( - val - && typeof val === 'object' - && Array.isArray(val.refAttr) - && Object.prototype.hasOwnProperty.call(val, 'task') - && typeof val.exec === 'function', - ); - }, - print(val, serialize) { - const printed = serialize({ - refAttr: Array.isArray(val.refAttr) ? [...val.refAttr] : val.refAttr, - task: val.task, - }); - if (printed.startsWith('Object')) { - return printed.replace(/^Object/, 'RefProxy'); - } - if (printed.startsWith('{')) { - return `RefProxy ${printed}`; - } - return printed; - }, -}); +} from '../../../../runtime/lib/snapshot/index.js'; +import { destroyWorklet } from '../../../../runtime/lib/worklet/destroy.js'; +import { initApiEnv } from '../../../../runtime/lib/worklet-runtime/api/lynxApi.js'; +import { initEventListeners } from '../../../../runtime/lib/worklet-runtime/listeners.js'; +import { initWorklet } from '../../../../runtime/lib/worklet-runtime/workletRuntime.js'; const { onInjectMainThreadGlobals, @@ -110,8 +84,12 @@ globalThis.onInjectMainThreadGlobals = (target) => { target.globalPipelineOptions = undefined; - if (typeof __ALOG_ELEMENT_API__ !== 'undefined' && __ALOG_ELEMENT_API__) { + if ( + typeof target.__ALOG_ELEMENT_API__ !== 'undefined' && target.__ALOG_ELEMENT_API__ + && !target.__initElementPAPICallAlogInjected + ) { initElementPAPICallAlog(target); + target.__initElementPAPICallAlogInjected = true; } }; globalThis.onInjectBackgroundThreadGlobals = (target) => { @@ -146,13 +124,16 @@ globalThis.onInjectBackgroundThreadGlobals = (target) => { target._document = setupBackgroundDocument({}); target.globalPipelineOptions = undefined; - target.lynx.requireModuleAsync = async (url, callback) => { - try { - callback(null, await __vite_ssr_dynamic_import__(url)); - } catch (err) { - callback(err, null); - } - }; + // TODO: can we only inject to target(mainThread.globalThis) instead of globalThis? + // packages/react/runtime/src/lynx.ts + // intercept lynxCoreInject assignments to lynxTestingEnv.backgroundThread.globalThis.lynxCoreInject + const oldLynxCoreInject = globalThis.lynxCoreInject; + globalThis.lynxCoreInject = target.lynxCoreInject; + try { + injectTt(); + } finally { + globalThis.lynxCoreInject = oldLynxCoreInject; + } // re-init global snapshot patch to undefined deinitGlobalSnapshotPatch(); diff --git a/packages/react/testing-library/src/setupFiles/inner/rstest.js b/packages/react/testing-library/src/setupFiles/inner/rstest.js new file mode 100644 index 0000000000..26c510b596 --- /dev/null +++ b/packages/react/testing-library/src/setupFiles/inner/rstest.js @@ -0,0 +1,13 @@ +const { + onInjectBackgroundThreadGlobals, +} = globalThis; + +globalThis.onInjectBackgroundThreadGlobals = (target) => { + if (onInjectBackgroundThreadGlobals) { + onInjectBackgroundThreadGlobals(target); + } + + target.lynx.requireModuleAsync = async (url, _callback) => { + throw new Error('lynx.requireModuleAsync not implemented for rstest'); + }; +}; diff --git a/packages/react/testing-library/src/setupFiles/inner/vitest.js b/packages/react/testing-library/src/setupFiles/inner/vitest.js new file mode 100644 index 0000000000..b8421fa88f --- /dev/null +++ b/packages/react/testing-library/src/setupFiles/inner/vitest.js @@ -0,0 +1,17 @@ +const { + onInjectBackgroundThreadGlobals, +} = globalThis; + +globalThis.onInjectBackgroundThreadGlobals = (target) => { + if (onInjectBackgroundThreadGlobals) { + onInjectBackgroundThreadGlobals(target); + } + + target.lynx.requireModuleAsync = async (url, callback) => { + try { + callback(null, await __vite_ssr_dynamic_import__(url)); + } catch (err) { + callback(err, null); + } + }; +}; diff --git a/packages/react/testing-library/src/setupFiles/rstest.js b/packages/react/testing-library/src/setupFiles/rstest.js new file mode 100644 index 0000000000..ef0811e05f --- /dev/null +++ b/packages/react/testing-library/src/setupFiles/rstest.js @@ -0,0 +1,30 @@ +import '../env/rstest.js'; +import './common/runtime-setup.js'; +import './inner/rstest.js'; +import './common/bootstrap.js'; +import { expect } from '@rstest/core'; + +expect.addSnapshotSerializer({ + test(val) { + return Boolean( + val + && typeof val === 'object' + && Array.isArray(val.refAttr) + && Object.prototype.hasOwnProperty.call(val, 'task') + && typeof val.exec === 'function', + ); + }, + print(val, serialize) { + const printed = serialize({ + refAttr: Array.isArray(val.refAttr) ? [...val.refAttr] : val.refAttr, + task: val.task, + }); + if (printed.startsWith('Object')) { + return printed.replace(/^Object/, 'RefProxy'); + } + if (printed.startsWith('{')) { + return `RefProxy ${printed}`; + } + return printed; + }, +}); diff --git a/packages/react/testing-library/src/setupFiles/vitest.js b/packages/react/testing-library/src/setupFiles/vitest.js new file mode 100644 index 0000000000..71f8a24da1 --- /dev/null +++ b/packages/react/testing-library/src/setupFiles/vitest.js @@ -0,0 +1,29 @@ +import './common/runtime-setup.js'; +import './inner/vitest.js'; +import './common/bootstrap.js'; +import { expect } from 'vitest'; + +expect.addSnapshotSerializer({ + test(val) { + return Boolean( + val + && typeof val === 'object' + && Array.isArray(val.refAttr) + && Object.prototype.hasOwnProperty.call(val, 'task') + && typeof val.exec === 'function', + ); + }, + print(val, serialize) { + const printed = serialize({ + refAttr: Array.isArray(val.refAttr) ? [...val.refAttr] : val.refAttr, + task: val.task, + }); + if (printed.startsWith('Object')) { + return printed.replace(/^Object/, 'RefProxy'); + } + if (printed.startsWith('{')) { + return `RefProxy ${printed}`; + } + return printed; + }, +}); diff --git a/packages/react/testing-library/src/vitest.config.ts b/packages/react/testing-library/src/vitest.config.ts new file mode 100644 index 0000000000..56c9e84ba3 --- /dev/null +++ b/packages/react/testing-library/src/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, type ViteUserConfig } from 'vitest/config'; +import { vitestTestingLibraryPlugin } from './plugins/index.js'; +import type { TestingLibraryOptions } from './plugins/index.js'; + +/** + * @deprecated Use `vitestTestingLibraryPlugin` from `@lynx-js/react/testing-library/plugins` instead. + */ +export function createVitestConfig(options?: TestingLibraryOptions): ViteUserConfig { + return defineConfig({ + plugins: [ + vitestTestingLibraryPlugin(options), + ], + }); +} diff --git a/packages/react/testing-library/tsconfig.json b/packages/react/testing-library/tsconfig.json index aac4b8ccfb..06f9114c72 100644 --- a/packages/react/testing-library/tsconfig.json +++ b/packages/react/testing-library/tsconfig.json @@ -6,7 +6,7 @@ "rootDir": "src", "stripInternal": true, "target": "ESNext", - "lib": ["es2021"], + "lib": ["es2021", "dom"], "module": "Node16", "moduleResolution": "Node16", "resolveJsonModule": true, diff --git a/packages/react/testing-library/types/vitest-config.d.ts b/packages/react/testing-library/types/vitest-config.d.ts deleted file mode 100644 index 3b20fe6013..0000000000 --- a/packages/react/testing-library/types/vitest-config.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { ViteUserConfig } from 'vitest/config.js'; - -export interface CreateVitestConfigOptions { - /** - * The package name of the ReactLynx runtime package. - * - * @defaultValue `@lynx-js/react` - */ - runtimePkgName?: string; - /** - * Enable React Compiler for this build. - * - * @link https://react.dev/learn/react-compiler - * - * @defaultValue false - */ - experimental_enableReactCompiler?: boolean; -} - -export function createVitestConfig(options?: CreateVitestConfigOptions): Promise; diff --git a/packages/react/testing-library/vitest-polyfill.cjs b/packages/react/testing-library/vitest-polyfill.cjs new file mode 100644 index 0000000000..647f2e4a8d --- /dev/null +++ b/packages/react/testing-library/vitest-polyfill.cjs @@ -0,0 +1,6 @@ +// in order to make our test case work for +// both vitest and rstest, we need to alias +// `vitest` to `@rstest/core` + +global['@rstest/core'].vi = global['@rstest/core'].rs; +module.exports = global['@rstest/core']; diff --git a/packages/react/testing-library/vitest.3.1.config.ts b/packages/react/testing-library/vitest.3.1.config.ts index 56d18337e2..43706ec69d 100644 --- a/packages/react/testing-library/vitest.3.1.config.ts +++ b/packages/react/testing-library/vitest.3.1.config.ts @@ -1,17 +1,17 @@ -import { defineConfig, mergeConfig } from 'vitest/config'; -import { createVitestConfig } from './dist/vitest.config'; +import { defineConfig } from 'vitest/config'; +import { vitestTestingLibraryPlugin } from './dist/plugins/index.js'; -const defaultConfig = await createVitestConfig({ - runtimePkgName: '@lynx-js/react', - engineVersion: '3.1', - include: [ - 'src/__tests__/3.1/**/*.{js,jsx,ts,tsx}', +export default defineConfig({ + plugins: [ + vitestTestingLibraryPlugin({ + runtimePkgName: '@lynx-js/react', + engineVersion: '3.1', + }), ], -}); -const config = defineConfig({ test: { name: 'react/testing-library/engine-3.1', + include: [ + 'src/__tests__/3.1/**/*.{js,jsx,ts,tsx}', + ], }, }); - -export default mergeConfig(defaultConfig, config); diff --git a/packages/react/testing-library/vitest.config.ts b/packages/react/testing-library/vitest.config.ts index 65fcb43cdb..e2c0b2de2f 100644 --- a/packages/react/testing-library/vitest.config.ts +++ b/packages/react/testing-library/vitest.config.ts @@ -1,14 +1,14 @@ -import { defineConfig, mergeConfig } from 'vitest/config'; -import { createVitestConfig } from './dist/vitest.config'; +import { defineConfig } from 'vitest/config'; +import { vitestTestingLibraryPlugin } from './dist/plugins/index.js'; -const defaultConfig = await createVitestConfig({ - runtimePkgName: '@lynx-js/react', - include: ['src/**/*.test.{js,jsx,ts,tsx}', '!src/__tests__/3.1/**/*.{js,jsx,ts,tsx}'], -}); -const config = defineConfig({ +export default defineConfig({ + plugins: [ + vitestTestingLibraryPlugin({ + runtimePkgName: '@lynx-js/react', + }), + ], test: { name: 'react/testing-library', + include: ['src/**/*.test.{js,jsx,ts,tsx}', '!src/__tests__/3.1/**/*.{js,jsx,ts,tsx}'], }, }); - -export default mergeConfig(defaultConfig, config); diff --git a/packages/rspeedy/core/CHANGELOG.md b/packages/rspeedy/core/CHANGELOG.md index 29231f4faa..6bfa129584 100644 --- a/packages/rspeedy/core/CHANGELOG.md +++ b/packages/rspeedy/core/CHANGELOG.md @@ -521,7 +521,7 @@ - Add `callerName` option to `createRspeedy`. ([#757](https://github.com/lynx-family/lynx-stack/pull/757)) - It can be accessed by Rsbuild plugins through [`api.context.callerName`](https://rsbuild.dev/api/javascript-api/instance#contextcallername), and execute different logic based on this identifier. + It can be accessed by Rsbuild plugins through [`api.context.callerName`](https://rsbuild.rs/api/javascript-api/instance#contextcallername), and execute different logic based on this identifier. ```js export const myPlugin = { @@ -652,7 +652,7 @@ - Support `output.distPath.*`. ([#366](https://github.com/lynx-family/lynx-stack/pull/366)) - See [Rsbuild - distPath](https://rsbuild.dev/config/output/dist-path) for all available options. + See [Rsbuild - distPath](https://rsbuild.rs/config/output/dist-path) for all available options. - Support `performance.printFileSize` ([#336](https://github.com/lynx-family/lynx-stack/pull/336)) diff --git a/packages/rspeedy/core/src/api.ts b/packages/rspeedy/core/src/api.ts index 4361b53643..00a8e0f226 100644 --- a/packages/rspeedy/core/src/api.ts +++ b/packages/rspeedy/core/src/api.ts @@ -6,7 +6,7 @@ import type { logger } from '@rsbuild/core' import type { Config } from './config/index.js' /** - * The exposed API of Rspeedy. Can be used in Rsbuild plugin with {@link https://rsbuild.dev/plugins/dev/core#apiuseexposed | api.useExposed}. + * The exposed API of Rspeedy. Can be used in Rsbuild plugin with {@link https://rsbuild.rs/plugins/dev/core#apiuseexposed | api.useExposed}. * * @public * diff --git a/packages/rspeedy/core/src/config/dev/index.ts b/packages/rspeedy/core/src/config/dev/index.ts index 3a66d238ff..c743e6bec2 100644 --- a/packages/rspeedy/core/src/config/dev/index.ts +++ b/packages/rspeedy/core/src/config/dev/index.ts @@ -19,7 +19,7 @@ export interface Dev { * * During `rspeedy dev`, if this option is not set to `false`, the dev plugin normalizes it to `http://:/` and appends `server.base` when configured. * - * The functionality of {@link Dev.assetPrefix} is basically the same as the {@link https://www.rspack.dev/config/output#outputpublicpath | output.publicPath} + * The functionality of {@link Dev.assetPrefix} is basically the same as the {@link https://rspack.rs/config/output#outputpublicpath | output.publicPath} * config in Rspack. With the following differences: * * - `dev.assetPrefix` only takes effect during development. diff --git a/packages/rspeedy/core/src/config/index.ts b/packages/rspeedy/core/src/config/index.ts index 764efd761f..ba644ea439 100644 --- a/packages/rspeedy/core/src/config/index.ts +++ b/packages/rspeedy/core/src/config/index.ts @@ -81,7 +81,7 @@ export interface Config { * * If the value of `mode` is `'development'`: * - * - Enable HMR and register the {@link https://rspack.dev/plugins/webpack/hot-module-replacement-plugin | HotModuleReplacementPlugin}. + * - Enable HMR and register the {@link https://rspack.rs/plugins/webpack/hot-module-replacement-plugin | HotModuleReplacementPlugin}. * * - Generate JavaScript source maps, but do not generate CSS source maps. See {@link Output.sourceMap} for details. * @@ -97,7 +97,7 @@ export interface Config { * * If the value of `mode` is `'production'`: * - * - Enable JavaScript code minification and register the {@link https://rspack.dev/plugins/rspack/swc-js-minimizer-rspack-plugin | SwcJsMinimizerRspackPlugin}. + * - Enable JavaScript code minification and register the {@link https://rspack.rs/plugins/rspack/swc-js-minimizer-rspack-plugin | SwcJsMinimizerRspackPlugin}. * * - Generated JavaScript and CSS filenames will have hash suffixes, see {@link Output.filenameHash}. * @@ -165,7 +165,7 @@ export interface Config { * @defaultValue undefined * * @remarks - * Rspeedy use the plugin APIs from {@link https://rsbuild.dev/plugins/dev/index | Rsbuild}. See the corresponding document for developing a plugin. + * Rspeedy uses the plugin APIs from {@link https://rsbuild.rs/plugins/dev/index | Rsbuild}. See the corresponding document for developing a plugin. */ plugins?: RsbuildPlugins | undefined } diff --git a/packages/rspeedy/core/src/config/mergeRspeedyConfig.ts b/packages/rspeedy/core/src/config/mergeRspeedyConfig.ts index c41c36bb28..11bf5d6fac 100644 --- a/packages/rspeedy/core/src/config/mergeRspeedyConfig.ts +++ b/packages/rspeedy/core/src/config/mergeRspeedyConfig.ts @@ -36,7 +36,7 @@ import type { Config } from './index.js' * * @remarks * - * This is actually an alias of {@link https://rsbuild.dev/api/javascript-api/core#mergersbuildconfig | mergeRsbuildConfig}. + * This is actually an alias of {@link https://rsbuild.rs/api/javascript-api/core#mergersbuildconfig | mergeRsbuildConfig}. * * @public */ diff --git a/packages/rspeedy/core/src/config/output/index.ts b/packages/rspeedy/core/src/config/output/index.ts index eecbe3eb21..f8f17f1cfe 100644 --- a/packages/rspeedy/core/src/config/output/index.ts +++ b/packages/rspeedy/core/src/config/output/index.ts @@ -21,7 +21,7 @@ export interface Output { * * @remarks * - * The functionality of {@link Output.assetPrefix} is basically the same as the {@link https://www.rspack.dev/config/output#outputpublicpath | output.publicPath} + * The functionality of {@link Output.assetPrefix} is basically the same as the {@link https://rspack.rs/config/output#outputpublicpath | output.publicPath} * config in Rspack. With the following differences: * * - `output.assetPrefix` only takes effect in the production build. @@ -46,6 +46,12 @@ export interface Output { /** * The {@link Output.cleanDistPath} option determines whether all files in the output directory (default: `dist`) are removed before the build starts. * + * @remarks + * + * By default, if the output directory is a subdirectory of the project root path, Rspeedy will automatically clean all files in the build directory. + * + * When {@link https://rsbuild.rs/config/output/dist-path#root-directory | output.distPath.root} is an external directory or the same as the project root directory, `cleanDistPath` is not enabled by default to prevent accidental deletion of files from other directories. + * * @defaultValue Automatically enabled when `output.distPath.root` is a subdirectory of the project root; otherwise disabled. * * @example @@ -82,7 +88,7 @@ export interface Output { * * @remarks * - * For more options, see {@link https://rspack.dev/plugins/rspack/copy-rspack-plugin | Rspack.CopyRspackPlugin}. + * For more options, see {@link https://rspack.rs/plugins/rspack/copy-rspack-plugin | Rspack.CopyRspackPlugin}. * * @example * @@ -234,7 +240,7 @@ export interface Output { * * @remarks * - * More options can be found at {@link https://rsbuild.dev/config/output/dist-path | Rsbuild - distPath}. + * More options can be found at {@link https://rsbuild.rs/config/output/dist-path | Rsbuild - distPath}. * * @example * @@ -329,7 +335,7 @@ export interface Output { * * @remarks * - * This is different with {@link https://rsbuild.dev/config/output/inline-scripts | output.inlineScripts } since we normally want to inline scripts in Lynx bundle (`.lynx.bundle`). + * This is different with {@link https://rsbuild.rs/config/output/inline-scripts | output.inlineScripts } since we normally want to inline scripts in Lynx bundle (`.lynx.bundle`). * * There are two points that need to be especially noted: * diff --git a/packages/rspeedy/core/src/config/output/minify.ts b/packages/rspeedy/core/src/config/output/minify.ts index 9998a62293..f6338c5ea9 100644 --- a/packages/rspeedy/core/src/config/output/minify.ts +++ b/packages/rspeedy/core/src/config/output/minify.ts @@ -67,7 +67,7 @@ export interface Minify { * * @remarks * - * For detailed configurations, please refer to {@link https://rspack.dev/plugins/rspack/swc-js-minimizer-rspack-plugin | SwcJsMinimizerRspackPlugin}. + * For detailed configurations, please refer to {@link https://rspack.rs/plugins/rspack/swc-js-minimizer-rspack-plugin | SwcJsMinimizerRspackPlugin}. * * @example * diff --git a/packages/rspeedy/core/src/config/output/source-map.ts b/packages/rspeedy/core/src/config/output/source-map.ts index 033a423222..af796a3bf6 100644 --- a/packages/rspeedy/core/src/config/output/source-map.ts +++ b/packages/rspeedy/core/src/config/output/source-map.ts @@ -16,7 +16,7 @@ export interface SourceMap { * * @remarks * - * See {@link https://rspack.dev/config/devtool | Rspack - Devtool} for details. + * See {@link https://rspack.rs/config/devtool | Rspack - Devtool} for details. * * @example * diff --git a/packages/rspeedy/core/src/config/performance/index.ts b/packages/rspeedy/core/src/config/performance/index.ts index c5eff5312b..2f525e88ed 100644 --- a/packages/rspeedy/core/src/config/performance/index.ts +++ b/packages/rspeedy/core/src/config/performance/index.ts @@ -80,7 +80,7 @@ export interface Performance { chunkSplit?: ChunkSplit | ChunkSplitBySize | ChunkSplitCustom | undefined /** - * Whether capture timing information in the build time and the runtime, the same as the {@link https://rspack.dev/config/other-options#profile | profile} config of Rspack. + * Whether capture timing information in the build time and the runtime, the same as the {@link https://rspack.rs/config/other-options#profile | profile} config of Rspack. * * @defaultValue Rspeedy sets this to `true` when `DEBUG` contains `rspeedy`; otherwise it leaves the option unset. * @@ -146,7 +146,7 @@ export interface Performance { * * {@link Performance.printFileSize} * - * See {@link https://rsbuild.dev/config/performance/print-file-size | Rsbuild - performance.printFileSize} for details. + * See {@link https://rsbuild.rs/config/performance/print-file-size | Rsbuild - performance.printFileSize} for details. * * @example * diff --git a/packages/rspeedy/core/src/config/server/index.ts b/packages/rspeedy/core/src/config/server/index.ts index 3b0e4b9d30..314dbf7e23 100644 --- a/packages/rspeedy/core/src/config/server/index.ts +++ b/packages/rspeedy/core/src/config/server/index.ts @@ -19,7 +19,7 @@ export interface Server { * * If you want to access lynx bundle through `http://:/foo/main.lynx.bundle`, you can change `server.base` to `/foo` * - * you can refer to {@link https://rsbuild.dev/config/server/base | server.base } for more information. + * you can refer to {@link https://rsbuild.rs/config/server/base | server.base } for more information. * * @example * diff --git a/packages/rspeedy/core/src/config/source/entry.ts b/packages/rspeedy/core/src/config/source/entry.ts index de7215cd2e..9e67a8c3a7 100644 --- a/packages/rspeedy/core/src/config/source/entry.ts +++ b/packages/rspeedy/core/src/config/source/entry.ts @@ -6,7 +6,7 @@ * The `EntryDescription` describes a entry. It is useful when the project has multiple entries with different configuration. * * @remarks - * It is similar with the {@link https://www.rspack.dev/config/entry#entry-description-object | Entry Description Object} of Rspack. + * It is similar with the {@link https://rspack.rs/config/entry#entry-description-object | Entry Description Object} of Rspack. * But only a few properties that Lynx supports is allowed. * * @public diff --git a/packages/rspeedy/core/src/config/source/index.ts b/packages/rspeedy/core/src/config/source/index.ts index 5dd6c7590d..723255e47d 100644 --- a/packages/rspeedy/core/src/config/source/index.ts +++ b/packages/rspeedy/core/src/config/source/index.ts @@ -33,7 +33,7 @@ export interface Source { * Through the source.assetsInclude config, you can specify additional file types that should be treated as static assets. * These added static assets are processed using the same rules as the built-in supported static assets。 * - * The usage of `source.assetsInclude` is consistent with {@link https://rspack.dev/config/module#condition | Condition} + * The usage of `source.assetsInclude` is consistent with {@link https://rspack.rs/config/module#condition | Condition} * in Rspack, which supports passing in strings, regular expressions, arrays of conditions, or logical conditions * to match the module path or assets. * @@ -237,7 +237,7 @@ export interface Source { * By default, Rsbuild compiles JavaScript files in the current directory and TypeScript/JSX files * in all directories. Through the `source.exclude` config, you can specify files or directories * that should be excluded from compilation. - * The usage of `source.exclude` is consistent with {@link https://rspack.dev/config/module#ruleexclude | Rule.exclude} + * The usage of `source.exclude` is consistent with {@link https://rspack.rs/config/module#ruleexclude | Rule.exclude} * in Rspack, which supports passing in strings or regular expressions to match module paths. * * @example @@ -304,7 +304,7 @@ export interface Source { * * Through the `source.include` config, you can specify directories or modules * that need to be compiled by Rsbuild. - * The usage of `source.include` is consistent with {@link https://rspack.dev/config/module#ruleinclude | Rule.include} + * The usage of `source.include` is consistent with {@link https://rspack.rs/config/module#ruleinclude | Rule.include} * in Rspack, which supports passing in strings or regular expressions to match the module path. * * @example @@ -377,7 +377,7 @@ export interface Source { * * @remarks * - * See {@link https://rsbuild.dev/config/source/pre-entry | source.preEntry} for more details. + * See {@link https://rsbuild.rs/config/source/pre-entry | source.preEntry} for more details. * * @example * diff --git a/packages/rspeedy/core/src/config/tools/index.ts b/packages/rspeedy/core/src/config/tools/index.ts index 4146227750..ec3894617d 100644 --- a/packages/rspeedy/core/src/config/tools/index.ts +++ b/packages/rspeedy/core/src/config/tools/index.ts @@ -18,7 +18,7 @@ export type RsdoctorRspackPluginOptions = ConstructorParameters< */ export interface Tools { /** - * The {@link Tools.bundlerChain} changes the options of {@link https://www.rspack.dev | Rspack} using {@link https://github.com/rspack-contrib/rspack-chain | rspack-chain}. + * The {@link Tools.bundlerChain} changes the options of {@link https://rspack.rs | Rspack} using {@link https://github.com/rspack-contrib/rspack-chain | rspack-chain}. * * @defaultValue undefined * @@ -66,7 +66,7 @@ export interface Tools { cssLoader?: CssLoader | undefined /** - * The {@link CssExtract} controls the options of {@link https://www.rspack.dev/plugins/rspack/css-extract-rspack-plugin | CssExtractRspackPlugin} + * The {@link CssExtract} controls the options of {@link https://rspack.rs/plugins/rspack/css-extract-rspack-plugin | CssExtractRspackPlugin} * * @defaultValue undefined */ @@ -101,7 +101,7 @@ export interface Tools { rsdoctor?: RsdoctorRspackPluginOptions | undefined /** - * The {@link Tools.rspack} controls the options of {@link https://www.rspack.dev/ | Rspack}. + * The {@link Tools.rspack} controls the options of {@link https://rspack.rs/ | Rspack}. * * @defaultValue undefined * @@ -123,7 +123,7 @@ export interface Tools { * }) * ``` * - * See {@link https://www.rspack.dev/config/index | Rspack - Configuration} for details. + * See {@link https://rspack.rs/config/index | Rspack - Configuration} for details. * * @example * @@ -144,7 +144,7 @@ export interface Tools { * }) * ``` * - * See {@link https://rsbuild.dev/config/tools/rspack#env | Rsbuild - tools.rspack} for details. + * See {@link https://rsbuild.rs/config/tools/rspack#env | Rsbuild - tools.rspack} for details. * * @example * @@ -166,7 +166,7 @@ export interface Tools { * }) * ``` * - * See {@link https://rsbuild.dev/config/tools/rspack#mergeconfig | Rsbuild - tools.rspack} for details. + * See {@link https://rsbuild.rs/config/tools/rspack#mergeconfig | Rsbuild - tools.rspack} for details. * * @example * @@ -185,12 +185,12 @@ export interface Tools { * }) * ``` * - * See {@link https://rsbuild.dev/config/tools/rspack#appendplugins | Rsbuild - tools.rspack} for details. + * See {@link https://rsbuild.rs/config/tools/rspack#appendplugins | Rsbuild - tools.rspack} for details. */ rspack?: ToolsConfig['rspack'] | undefined /** - * The {@link Tools.swc} controls the options of {@link https://rspack.dev/guide/features/builtin-swc-loader | builtin:swc-loader}. + * The {@link Tools.swc} controls the options of {@link https://rspack.rs/guide/features/builtin-swc-loader | builtin:swc-loader}. * * @defaultValue undefined */ diff --git a/packages/rspeedy/core/src/create-rspeedy.ts b/packages/rspeedy/core/src/create-rspeedy.ts index b41ea12fe6..5b0bd3fd57 100644 --- a/packages/rspeedy/core/src/create-rspeedy.ts +++ b/packages/rspeedy/core/src/create-rspeedy.ts @@ -41,9 +41,9 @@ export interface CreateRspeedyOptions { */ rspeedyConfig?: Config /** - * Rspeedy automatically loads the .env file by default, utilizing the [Rsbuild API](https://rsbuild.dev/api/javascript-api/core#load-env-variables). + * Rspeedy automatically loads the .env file by default, utilizing the [Rsbuild API](https://rsbuild.rs/api/javascript-api/core#load-env-variables). * You can use the environment variables defined in the .env file within your code by accessing them via `import.meta.env.FOO` or `process.env.Foo`. - * @see https://rsbuild.dev/guide/advanced/env-vars#env-file + * @see https://rsbuild.rs/guide/advanced/env-vars#env-file * @defaultValue true */ loadEnv?: CreateRsbuildOptions['loadEnv'] @@ -51,7 +51,7 @@ export interface CreateRspeedyOptions { * Only build specified environments. * For example, passing `['lynx']` will only build the `lynx` environment. * If not specified or passing an empty array, all environments will be built. - * @see https://rsbuild.dev/guide/advanced/environments#build-specified-environment + * @see https://rsbuild.rs/guide/advanced/environments#build-specified-environment * @defaultValue [] */ environment?: CreateRsbuildOptions['environment'] diff --git a/packages/rspeedy/core/src/plugins/optimization.plugin.ts b/packages/rspeedy/core/src/plugins/optimization.plugin.ts index 8c55f6df27..62c47028ca 100644 --- a/packages/rspeedy/core/src/plugins/optimization.plugin.ts +++ b/packages/rspeedy/core/src/plugins/optimization.plugin.ts @@ -26,7 +26,7 @@ export function pluginOptimization(): RsbuildPlugin { if (isProd) { // Avoid entry being wrapped by IIFE - // See: https://rspack.dev/config/optimization#optimizationavoidentryiife + // See: https://rspack.rs/config/optimization#optimizationavoidentryiife chain .optimization .avoidEntryIife(true) diff --git a/packages/rspeedy/create-rspeedy/package.json b/packages/rspeedy/create-rspeedy/package.json index be3e067105..1e65491320 100644 --- a/packages/rspeedy/create-rspeedy/package.json +++ b/packages/rspeedy/create-rspeedy/package.json @@ -42,7 +42,8 @@ "@lynx-js/react": "workspace:^", "@lynx-js/react-rsbuild-plugin": "workspace:^", "@lynx-js/rspeedy": "workspace:^", - "@rsbuild/plugin-type-check": "1.3.4" + "@rsbuild/plugin-type-check": "1.3.4", + "@rstest/core": "catalog:rstest" }, "engines": { "node": ">=18" diff --git a/packages/rspeedy/create-rspeedy/src/index.ts b/packages/rspeedy/create-rspeedy/src/index.ts index 6dac880580..fdfd853223 100644 --- a/packages/rspeedy/create-rspeedy/src/index.ts +++ b/packages/rspeedy/create-rspeedy/src/index.ts @@ -82,7 +82,7 @@ void create({ extraTools: [ { value: 'vitest-rltl', - label: 'ReactLynx Testing Library - unit testing', + label: 'Vitest', order: 'pre', when: (templateName) => templateName === 'react-js' || templateName === 'react-ts', @@ -96,6 +96,22 @@ void create({ addAgentsMdSearchDirs(from) }, }, + { + value: 'rstest-rltl', + label: 'Rstest', + order: 'pre', + when: (templateName) => + templateName === 'react-js' || templateName === 'react-ts', + action: ({ distFolder, addAgentsMdSearchDirs }) => { + const from = path.resolve(__dirname, '..', 'template-react-rstest-rltl') + copyFolder({ + from, + to: distFolder, + isMergePackageJson: true, + }) + addAgentsMdSearchDirs(from) + }, + }, ], extraSkills: [ { diff --git a/packages/rspeedy/create-rspeedy/template-react-rstest-rltl/package.json b/packages/rspeedy/create-rspeedy/template-react-rstest-rltl/package.json new file mode 100644 index 0000000000..87105c08e4 --- /dev/null +++ b/packages/rspeedy/create-rspeedy/template-react-rstest-rltl/package.json @@ -0,0 +1,10 @@ +{ + "scripts": { + "test": "rstest run" + }, + "devDependencies": { + "@rstest/core": "^0.8.1", + "@testing-library/jest-dom": "^6.9.1", + "jsdom": "^27.4.0" + } +} diff --git a/packages/rspeedy/create-rspeedy/template-react-rstest-rltl/rstest.config.js b/packages/rspeedy/create-rspeedy/template-react-rstest-rltl/rstest.config.js new file mode 100644 index 0000000000..829e3b650f --- /dev/null +++ b/packages/rspeedy/create-rspeedy/template-react-rstest-rltl/rstest.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from '@rstest/core' +import { withLynxConfig } from '@lynx-js/react/testing-library/rstest-config' + +export default defineConfig({ + extends: withLynxConfig(), +}) diff --git a/packages/rspeedy/create-rspeedy/template-react-rstest-rltl/src/__tests__/index.test.jsx b/packages/rspeedy/create-rspeedy/template-react-rstest-rltl/src/__tests__/index.test.jsx new file mode 100644 index 0000000000..590db57517 --- /dev/null +++ b/packages/rspeedy/create-rspeedy/template-react-rstest-rltl/src/__tests__/index.test.jsx @@ -0,0 +1,17 @@ +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import '@testing-library/jest-dom' +import { expect, test } from '@rstest/core' +import { getQueriesForElement, render } from '@lynx-js/react/testing-library' + +import { App } from '../App' + +test('App', async () => { + render() + + const { findByText } = getQueriesForElement(elementTree.root) + const element = await findByText('Tap the logo and have fun!') + + expect(element).toBeInTheDocument() +}) diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl/vitest.config.js b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl/vitest.config.js index 98425e53c0..a311413043 100644 --- a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl/vitest.config.js +++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl/vitest.config.js @@ -1,9 +1,9 @@ -import { defineConfig, mergeConfig } from 'vitest/config' -import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config' +import { defineConfig } from 'vitest/config' +import { vitestTestingLibraryPlugin } from '@lynx-js/react/testing-library/plugins' -const defaultConfig = await createVitestConfig() -const config = defineConfig({ +export default defineConfig({ + plugins: [ + vitestTestingLibraryPlugin(), + ], test: {}, }) - -export default mergeConfig(defaultConfig, config) diff --git a/packages/rspeedy/plugin-react/package.json b/packages/rspeedy/plugin-react/package.json index 9de644426c..6f9d503a14 100644 --- a/packages/rspeedy/plugin-react/package.json +++ b/packages/rspeedy/plugin-react/package.json @@ -44,7 +44,8 @@ "@lynx-js/runtime-wrapper-webpack-plugin": "workspace:*", "@lynx-js/template-webpack-plugin": "workspace:*", "@lynx-js/use-sync-external-store": "workspace:*", - "background-only": "workspace:^" + "background-only": "workspace:^", + "tiny-invariant": "^1.3.3" }, "devDependencies": { "@lynx-js/config-rsbuild-plugin": "workspace:*", diff --git a/packages/rspeedy/plugin-react/src/css.ts b/packages/rspeedy/plugin-react/src/css.ts index 47955e8661..ed2daa94f7 100644 --- a/packages/rspeedy/plugin-react/src/css.ts +++ b/packages/rspeedy/plugin-react/src/css.ts @@ -32,7 +32,7 @@ export function applyCSS( // - disables `style-loader` // - enables CssExtractRspackPlugin // - disables `experiment.css`(which is all we need) - // See: https://rsbuild.dev/config/output/inject-styles + // See: https://rsbuild.rs/config/output/inject-styles output: { injectStyles: false }, }) }) diff --git a/packages/rspeedy/plugin-react/src/loaders.ts b/packages/rspeedy/plugin-react/src/loaders.ts index 2bc826a5fe..78d4b814f6 100644 --- a/packages/rspeedy/plugin-react/src/loaders.ts +++ b/packages/rspeedy/plugin-react/src/loaders.ts @@ -7,19 +7,67 @@ import { LAYERS, ReactWebpackPlugin } from '@lynx-js/react-webpack-plugin' import type { PluginReactLynxOptions } from './pluginReactLynx.js' -export function applyLoaders( +function getLoaderOptions( api: RsbuildPluginAPI, options: Required, -): void { + isMainThread = false, +) { + const { output } = api.getRsbuildConfig() + + const inlineSourcesContent: boolean = output?.sourceMap === true || !( + // `false` + output?.sourceMap === false + // `false` + || output?.sourceMap?.js === false + // explicitly disable source content + || output?.sourceMap?.js?.includes('nosources') + ) + const { compat, enableRemoveCSSScope, shake, defineDCE, engineVersion, + experimental_isLazyBundle, } = options + return { + compat, + enableRemoveCSSScope, + isDynamicComponent: experimental_isLazyBundle, + inlineSourcesContent, + defineDCE, + engineVersion, + ...isMainThread + ? { + shake, + } + : {}, + } +} + +const TESTING_RULE_NAME = 'react:testing' +export function applyTestingLoaders( + api: RsbuildPluginAPI, + options: Required, +): void { + api.modifyBundlerChain((chain, { CHAIN_ID }) => { + const rule = chain.module.rules.get(CHAIN_ID.RULE.JS) + + rule + .use(TESTING_RULE_NAME) + .loader(ReactWebpackPlugin.loaders.TESTING) + .options(getLoaderOptions(api, options)) + .end() + }) +} + +export function applyLoaders( + api: RsbuildPluginAPI, + options: Required, +): void { api.modifyBundlerChain((chain, { CHAIN_ID }) => { const rule = chain.module.rules.get(CHAIN_ID.RULE.JS) // The Rsbuild default loaders: @@ -30,17 +78,6 @@ export function applyLoaders( // - Webpack: None const uses = rule.uses.entries() ?? {} - const { output } = api.getRsbuildConfig() - - const inlineSourcesContent: boolean = output?.sourceMap === true || !( - // `false` - output?.sourceMap === false - // `false` - || output?.sourceMap?.js === false - // explicitly disable source content - || output?.sourceMap?.js?.includes('nosources') - ) - const backgroundRule = rule.oneOf(LAYERS.BACKGROUND) // dprint-ignore backgroundRule @@ -50,14 +87,7 @@ export function applyLoaders( .end() .use(LAYERS.BACKGROUND) .loader(ReactWebpackPlugin.loaders.BACKGROUND) - .options({ - compat, - enableRemoveCSSScope, - isDynamicComponent: experimental_isLazyBundle, - inlineSourcesContent, - defineDCE, - engineVersion, - }) + .options(getLoaderOptions(api, options)) .end() const mainThreadRule = rule.oneOf(LAYERS.MAIN_THREAD) @@ -89,15 +119,7 @@ export function applyLoaders( }) .use(LAYERS.MAIN_THREAD) .loader(ReactWebpackPlugin.loaders.MAIN_THREAD) - .options({ - compat, - enableRemoveCSSScope, - inlineSourcesContent, - isDynamicComponent: experimental_isLazyBundle, - engineVersion, - shake, - defineDCE, - }) + .options(getLoaderOptions(api, options, true)) .end() // Clear the Rsbuild default loader. diff --git a/packages/rspeedy/plugin-react/src/pluginReactLynx.ts b/packages/rspeedy/plugin-react/src/pluginReactLynx.ts index 20761ecfc7..c3435ae75a 100644 --- a/packages/rspeedy/plugin-react/src/pluginReactLynx.ts +++ b/packages/rspeedy/plugin-react/src/pluginReactLynx.ts @@ -28,7 +28,7 @@ import { applyCSS } from './css.js' import { applyEntry } from './entry.js' import { applyGenerator } from './generator.js' import { applyLazy } from './lazy.js' -import { applyLoaders } from './loaders.js' +import { applyLoaders, applyTestingLoaders } from './loaders.js' import { applyNodeEnv } from './nodeEnv.js' import { applyOptimizeBundleSize } from './optimizeBundleSize.js' import { applyRefresh } from './refresh.js' @@ -389,6 +389,7 @@ export function pluginReactLynx( pre: ['lynx:rsbuild:plugin-api', 'lynx:config'], setup(api) { const isRslib = api.context.callerName === 'rslib' + const isRstest = api.context.callerName === 'rstest' const exposedConfig = api.useExposed<{ config: Config }>( Symbol.for('lynx.config'), @@ -403,11 +404,17 @@ export function pluginReactLynx( }) } - applyCSS(api, resolvedOptions) + if (!isRstest) { + applyCSS(api, resolvedOptions) + } applyEntry(api, resolvedOptions) applyBackgroundOnly(api) applyGenerator(api, resolvedOptions) - applyLoaders(api, resolvedOptions) + if (isRstest) { + applyTestingLoaders(api, resolvedOptions) + } else { + applyLoaders(api, resolvedOptions) + } applyRefresh(api) applySplitChunksRule(api) applySWC(api) diff --git a/packages/rspeedy/plugin-react/src/refresh.ts b/packages/rspeedy/plugin-react/src/refresh.ts index 3ea517e03a..a440468d7e 100644 --- a/packages/rspeedy/plugin-react/src/refresh.ts +++ b/packages/rspeedy/plugin-react/src/refresh.ts @@ -9,12 +9,20 @@ import type { RspackChain, } from '@rsbuild/core' -import { ReactRefreshRspackPlugin } from '@lynx-js/react-refresh-webpack-plugin' +import { + ReactRefreshRspackPlugin, + ReactRefreshWebpackPlugin, +} from '@lynx-js/react-refresh-webpack-plugin' import { LAYERS } from '@lynx-js/react-webpack-plugin' const PLUGIN_NAME_REACT_REFRESH = 'lynx:react:refresh' export function applyRefresh(api: RsbuildPluginAPI): void { + api.modifyWebpackChain?.(async (chain, { CHAIN_ID, isProd }) => { + if (!isProd) { + await applyRefreshRules(api, chain, CHAIN_ID, ReactRefreshWebpackPlugin) + } + }) api.modifyBundlerChain(async (chain, { isProd, CHAIN_ID }) => { if (!isProd) { // biome-ignore lint/correctness/useHookAtTopLevel: not react hooks @@ -32,11 +40,12 @@ export function applyRefresh(api: RsbuildPluginAPI): void { }) } -async function applyRefreshRules( +async function applyRefreshRules( api: RsbuildPluginAPI, chain: RspackChain, CHAIN_ID: ChainIdentifier, - ReactRefreshPlugin: typeof ReactRefreshRspackPlugin, + ReactRefreshPlugin: Bundler extends 'rspack' ? typeof ReactRefreshRspackPlugin + : typeof ReactRefreshWebpackPlugin, ) { // biome-ignore lint/correctness/useHookAtTopLevel: not react hooks const { resolve } = api.useExposed< diff --git a/packages/rspeedy/plugin-react/src/rspeedy-api.ts b/packages/rspeedy/plugin-react/src/rspeedy-api.ts new file mode 100644 index 0000000000..5a2de15cc6 --- /dev/null +++ b/packages/rspeedy/plugin-react/src/rspeedy-api.ts @@ -0,0 +1,14 @@ +// 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 invariant from 'tiny-invariant' + +import type { ExposedAPI, RsbuildPluginAPI } from '@lynx-js/rspeedy' + +export function getRspeedyAPI(api: RsbuildPluginAPI): ExposedAPI { + const rspeedyAPI = api.useExposed( + Symbol.for('rspeedy.api'), + )! + invariant(rspeedyAPI, 'Should have rspeedy.api') + return rspeedyAPI +} diff --git a/packages/rspeedy/plugin-react/test/config.test.ts b/packages/rspeedy/plugin-react/test/config.test.ts index 9457fb4344..2e28a62396 100644 --- a/packages/rspeedy/plugin-react/test/config.test.ts +++ b/packages/rspeedy/plugin-react/test/config.test.ts @@ -2063,7 +2063,7 @@ describe('Config', () => { vi.stubEnv('NODE_ENV', 'production') const entryName = 'defineDCE' - const rsbuild = await createRspeedy({ + const rsbuild = await createRspeedyWithTempDistRoot({ rspeedyConfig: { source: { entry: { @@ -2096,18 +2096,28 @@ describe('Config', () => { expect.fail('build should succeed') } - const distPath = path.join( - rsbuild.context.distPath, - '.rspeedy', - entryName, - 'main-thread.js', + const candidateOutputPaths = [ + path.join( + rsbuild.context.distPath, + '.rspeedy', + entryName, + 'main-thread.js', + ), + path.join(rsbuild.context.distPath, `${entryName}.lynx.bundle`), + ] + const builtOutputPath = candidateOutputPaths.find( + outputPath => existsSync(outputPath), ) - if (!existsSync(distPath)) { - expect.fail(`Build output should exist at ${distPath}`) + if (!builtOutputPath) { + expect.fail( + `Build output should exist in one of: ${ + candidateOutputPaths.join(', ') + }`, + ) } - const builtCode = readFileSync(distPath, 'utf8') + const builtCode = readFileSync(builtOutputPath, 'utf8') expect(builtCode).not.toContain('profileStart(\'test\')') expect(builtCode).toContain('Config is: profile-off-mode') }) @@ -2740,6 +2750,75 @@ describe('Config', () => { ).toBe('main/background.js') }) }) + + describe('callerName: rstest', async () => { + const { pluginReactLynx } = await import('../src/pluginReactLynx.js') + + const rsbuild = await createRspeedy({ + rspeedyConfig: { + plugins: [ + pluginReactLynx(), + ], + }, + callerName: 'rstest', + }) + const [config] = await rsbuild.initConfigs() + interface Rule { + test?: RegExp + use?: Array<{ loader: string }> + [key: string]: unknown + } + + const rules = config?.module?.rules as Rule[] | undefined + + test('css rules should be rsbuild default', () => { + expect( + rules?.filter((rule: Rule) => + rule + && typeof rule === 'object' + && rule.test + && rule.test.toString() === (/\.css$/).toString() + ).map((rule: Rule) => + (rule?.use?.map((u: { loader: string }) => u.loader)) ?? [] + ), + ).toMatchInlineSnapshot(` + [ + [ + "/node_modules//@rspack/core/dist/cssExtractLoader.js", + "/node_modules//@rsbuild/core/compiled/css-loader/index.js", + "builtin:lightningcss-loader", + ], + [ + "/node_modules//@rsbuild/core/compiled/css-loader/index.js", + "builtin:lightningcss-loader", + ], + [], + ] + `) + }) + test('js loaders should be testing loaders', () => { + expect( + rules?.filter((rule: Rule) => + rule + && typeof rule === 'object' + && rule.test + && rule.test.toString() + === (/\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts)$/).toString() + ).map((rule: Rule) => + (rule?.use?.map((u: { loader: string }) => u.loader)) ?? [] + ), + ).toMatchInlineSnapshot(` + [ + [ + "builtin:swc-loader", + "/packages/webpack/react-webpack-plugin/lib/loaders/testing.js", + ], + [], + [], + ] + `) + }) + }) }) describe('MPA Config', () => { diff --git a/packages/testing-library/examples/basic/package.json b/packages/testing-library/examples/basic/package.json index 5c0f9c0a15..999c960b68 100644 --- a/packages/testing-library/examples/basic/package.json +++ b/packages/testing-library/examples/basic/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "rspeedy build", "dev": "rspeedy dev", + "rstest": "rstest", "test": "vitest", "test:type": "vitest --typecheck.only" }, diff --git a/packages/testing-library/examples/basic/rstest.config.ts b/packages/testing-library/examples/basic/rstest.config.ts new file mode 100644 index 0000000000..cd0c3bba3a --- /dev/null +++ b/packages/testing-library/examples/basic/rstest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@rstest/core'; +import { withLynxConfig } from '@lynx-js/react/testing-library/rstest-config'; + +export default defineConfig({ + extends: withLynxConfig(), +}); diff --git a/packages/testing-library/examples/basic/src/__tests__/index.test.tsx b/packages/testing-library/examples/basic/src/__tests__/index.test.tsx index d877834a08..1b6a7c4992 100644 --- a/packages/testing-library/examples/basic/src/__tests__/index.test.tsx +++ b/packages/testing-library/examples/basic/src/__tests__/index.test.tsx @@ -2,7 +2,6 @@ // 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 '@testing-library/jest-dom'; -import { expect, test, vi } from 'vitest'; import { render, getQueriesForElement } from '@lynx-js/react/testing-library'; // @ts-expect-error preact is aliased to the dep of @lynx-js/react import { Component as PreacComponent } from 'preact'; @@ -10,8 +9,15 @@ import { Component } from '@lynx-js/react'; import { App } from '../App.jsx'; +let fn; +if (typeof rstest !== 'undefined') { + fn = rstest.fn; +} else { + fn = vi.fn; +} + test('App', async () => { - const cb = vi.fn(); + const cb = fn(); render( +/// +/// diff --git a/packages/testing-library/examples/basic/vitest.config.ts b/packages/testing-library/examples/basic/vitest.config.ts index cbe326697f..37adc389db 100644 --- a/packages/testing-library/examples/basic/vitest.config.ts +++ b/packages/testing-library/examples/basic/vitest.config.ts @@ -1,13 +1,13 @@ -import { defineConfig, mergeConfig } from 'vitest/config'; -import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config'; +import { defineConfig } from 'vitest/config'; +import { vitestTestingLibraryPlugin } from '@lynx-js/react/testing-library/plugins'; -const defaultConfig = await createVitestConfig({ - runtimePkgName: '@lynx-js/react', -}); -const config = defineConfig({ +export default defineConfig({ + plugins: [ + vitestTestingLibraryPlugin({ + runtimePkgName: '@lynx-js/react', + }), + ], test: { name: 'testing-library/examples/basic', }, }); - -export default mergeConfig(defaultConfig, config); diff --git a/packages/testing-library/examples/library/package.json b/packages/testing-library/examples/library/package.json new file mode 100644 index 0000000000..c5887de3e6 --- /dev/null +++ b/packages/testing-library/examples/library/package.json @@ -0,0 +1,18 @@ +{ + "name": "@lynx-js/testing-library-example-library", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "rstest": "rstest run", + "test": "vitest run", + "test:rstest": "rstest run", + "test:vitest": "vitest run" + }, + "dependencies": { + "@lynx-js/react": "workspace:*" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1" + } +} diff --git a/packages/testing-library/examples/library/rstest.config.ts b/packages/testing-library/examples/library/rstest.config.ts new file mode 100644 index 0000000000..dc8d629688 --- /dev/null +++ b/packages/testing-library/examples/library/rstest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from '@rstest/core'; +import { withDefaultConfig } from '@lynx-js/react/testing-library/rstest-config'; + +export default defineConfig({ + extends: withDefaultConfig(), + name: 'testing-library/examples/library/rstest', +}); diff --git a/packages/testing-library/examples/library/src/useCounter.ts b/packages/testing-library/examples/library/src/useCounter.ts new file mode 100644 index 0000000000..5cffd43900 --- /dev/null +++ b/packages/testing-library/examples/library/src/useCounter.ts @@ -0,0 +1,23 @@ +import { useState } from '@lynx-js/react'; + +export interface UseCounterResult { + count: number; + inc: () => void; + dec: () => void; + reset: () => void; +} + +export function useCounter(initial = 0): UseCounterResult { + const [count, setCount] = useState(initial); + + const inc = (): void => setCount((v) => v + 1); + const dec = (): void => setCount((v) => v - 1); + const reset = (): void => setCount(initial); + + return { + count: count, + inc: inc, + dec: dec, + reset: reset, + }; +} diff --git a/packages/testing-library/examples/library/tests/useCounter.test.ts b/packages/testing-library/examples/library/tests/useCounter.test.ts new file mode 100644 index 0000000000..99e7c8d321 --- /dev/null +++ b/packages/testing-library/examples/library/tests/useCounter.test.ts @@ -0,0 +1,18 @@ +import { act, renderHook } from '@lynx-js/react/testing-library'; +import { useCounter } from '../src/useCounter.js'; + +describe('library example', () => { + it('updates hook state', () => { + const { result } = renderHook(() => useCounter(2)); + + expect(result.current.count).toBe(2); + + act(() => { + result.current.inc(); + result.current.inc(); + result.current.dec(); + }); + + expect(result.current.count).toBe(3); + }); +}); diff --git a/packages/testing-library/examples/library/tsconfig.json b/packages/testing-library/examples/library/tsconfig.json new file mode 100644 index 0000000000..beaf7aee1b --- /dev/null +++ b/packages/testing-library/examples/library/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "@lynx-js/react", + "types": ["vitest/globals", "@rstest/core/globals"], + "noEmit": true, + }, + "include": ["src", "tests"], +} diff --git a/packages/testing-library/examples/library/vitest.config.ts b/packages/testing-library/examples/library/vitest.config.ts new file mode 100644 index 0000000000..8100ef308d --- /dev/null +++ b/packages/testing-library/examples/library/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import { vitestTestingLibraryPlugin } from '@lynx-js/react/testing-library/plugins'; + +export default defineConfig({ + plugins: [ + vitestTestingLibraryPlugin({ + runtimePkgName: '@lynx-js/react', + }), + ], + test: { + name: 'testing-library/examples/library/vitest', + }, +}); diff --git a/packages/testing-library/examples/react-compiler/lynx.enable.config.ts b/packages/testing-library/examples/react-compiler/lynx.enable.config.ts new file mode 100644 index 0000000000..d2c5176d8a --- /dev/null +++ b/packages/testing-library/examples/react-compiler/lynx.enable.config.ts @@ -0,0 +1,35 @@ +import { pluginBabel } from '@rsbuild/plugin-babel'; + +import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'; +import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'; +import { defineConfig } from '@lynx-js/rspeedy'; + +export default defineConfig({ + source: { + entry: './src/index.tsx', + }, + plugins: [ + pluginReactLynx({ + enableRemoveCSSScope: true, + }), + pluginBabel({ + include: /\.(?:jsx|tsx)$/, + babelLoaderOptions(opts) { + opts.plugins?.unshift([ + 'babel-plugin-react-compiler', + // See https://react.dev/reference/react-compiler/configuration for config + { + // ReactLynx only supports target to version 17 + target: '17', + }, + ]); + }, + }), + pluginQRCode({ + schema(url) { + // We use `?fullscreen=true` to open the page in LynxExplorer in full screen mode + return `${url}?fullscreen=true`; + }, + }), + ], +}); diff --git a/packages/testing-library/examples/react-compiler/package.json b/packages/testing-library/examples/react-compiler/package.json index 3a5f1fe235..7824f3f319 100644 --- a/packages/testing-library/examples/react-compiler/package.json +++ b/packages/testing-library/examples/react-compiler/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "rspeedy build", "dev": "rspeedy dev", + "rstest": "rstest", "test": "vitest" }, "dependencies": { @@ -18,9 +19,11 @@ "@babel/plugin-syntax-typescript": "^7.28.6", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", + "@lynx-js/qrcode-rsbuild-plugin": "workspace:*", "@lynx-js/react-rsbuild-plugin": "workspace:*", "@lynx-js/rspeedy": "workspace:*", "@lynx-js/types": "3.7.0", + "@rsbuild/plugin-babel": "1.1.0", "@testing-library/jest-dom": "^6.9.1", "@types/react": "^18.3.28", "babel-plugin-react-compiler": "0.0.0-experimental-fe727a3-20250909", diff --git a/packages/testing-library/examples/react-compiler/rstest.config.ts b/packages/testing-library/examples/react-compiler/rstest.config.ts new file mode 100644 index 0000000000..36bd78acf3 --- /dev/null +++ b/packages/testing-library/examples/react-compiler/rstest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from '@rstest/core'; +import { withLynxConfig } from '@lynx-js/react/testing-library/rstest-config'; + +export default defineConfig({ + extends: withLynxConfig({ + configPath: './lynx.enable.config.ts', + }), + resolve: { + alias: { + // not necessary in real projects, just for compatibility with vitest tests in this repo + vitest: require.resolve('./vitest-polyfill.cjs'), + }, + }, + source: { + define: { + __FORGET__: 'true', + }, + }, +}); diff --git a/packages/testing-library/examples/react-compiler/vitest-polyfill.cjs b/packages/testing-library/examples/react-compiler/vitest-polyfill.cjs new file mode 100644 index 0000000000..647f2e4a8d --- /dev/null +++ b/packages/testing-library/examples/react-compiler/vitest-polyfill.cjs @@ -0,0 +1,6 @@ +// in order to make our test case work for +// both vitest and rstest, we need to alias +// `vitest` to `@rstest/core` + +global['@rstest/core'].vi = global['@rstest/core'].rs; +module.exports = global['@rstest/core']; diff --git a/packages/testing-library/examples/react-compiler/vitest.config.compiler-disabled.ts b/packages/testing-library/examples/react-compiler/vitest.config.compiler-disabled.ts index 1505d1e4b9..6a6b5a2207 100644 --- a/packages/testing-library/examples/react-compiler/vitest.config.compiler-disabled.ts +++ b/packages/testing-library/examples/react-compiler/vitest.config.compiler-disabled.ts @@ -1,11 +1,13 @@ -import { defineConfig, mergeConfig } from 'vitest/config'; -import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config'; +import { defineConfig } from 'vitest/config'; +import { vitestTestingLibraryPlugin } from '@lynx-js/react/testing-library/plugins'; -const defaultConfig = await createVitestConfig({ - runtimePkgName: '@lynx-js/react', - experimental_enableReactCompiler: false, -}); -const config = defineConfig({ +export default defineConfig({ + plugins: [ + vitestTestingLibraryPlugin({ + runtimePkgName: '@lynx-js/react', + experimental_enableReactCompiler: false, + }), + ], define: { __FORGET__: 'false', }, @@ -13,4 +15,3 @@ const config = defineConfig({ name: 'testing-library/examples/react-compiler-disabled', }, }); -export default mergeConfig(defaultConfig, config); diff --git a/packages/testing-library/examples/react-compiler/vitest.config.compiler-enabled.ts b/packages/testing-library/examples/react-compiler/vitest.config.compiler-enabled.ts index 7b66bfae5f..ca058654c0 100644 --- a/packages/testing-library/examples/react-compiler/vitest.config.compiler-enabled.ts +++ b/packages/testing-library/examples/react-compiler/vitest.config.compiler-enabled.ts @@ -1,11 +1,13 @@ -import { defineConfig, mergeConfig } from 'vitest/config'; -import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config'; +import { defineConfig } from 'vitest/config'; +import { vitestTestingLibraryPlugin } from '@lynx-js/react/testing-library/plugins'; -const defaultConfig = await createVitestConfig({ - runtimePkgName: '@lynx-js/react', - experimental_enableReactCompiler: true, -}); -const config = defineConfig({ +export default defineConfig({ + plugins: [ + vitestTestingLibraryPlugin({ + runtimePkgName: '@lynx-js/react', + experimental_enableReactCompiler: true, + }), + ], define: { __FORGET__: 'true', }, @@ -13,4 +15,3 @@ const config = defineConfig({ name: 'testing-library/examples/react-compiler-enabled', }, }); -export default mergeConfig(defaultConfig, config); diff --git a/packages/testing-library/testing-environment/README.md b/packages/testing-library/testing-environment/README.md index 9a67389df3..9d1cac587e 100644 --- a/packages/testing-library/testing-environment/README.md +++ b/packages/testing-library/testing-environment/README.md @@ -10,7 +10,7 @@ The Element PAPI implementation is based on jsdom, for example `__CreateElement` import { LynxTestingEnv } from '@lynx-js/testing-environment'; import { JSDOM } from 'jsdom'; -const lynxTestingEnv = new LynxTestingEnv(new JSDOM()); +const lynxTestingEnv = new LynxTestingEnv({ window: new JSDOM().window }); ``` To use `@lynx-js/testing-environment`, you will primarily use the `LynxTestingEnv` constructor, which is a named export of the package. You will get back a `LynxTestingEnv` instance, which has a number of methods of useful properties, notably `switchToMainThread` and `switchToBackgroundThread`, which allow you to switch between the main thread and background thread. @@ -66,6 +66,14 @@ If you want to use `@lynx-js/testing-environment` for unit testing in ReactLynx, Please refer to [ReactLynx Testing Library](https://lynxjs.org/react/reactlynx-testing-library.html) to inherit the configuration from `@lynx-js/react/testing-library`. +### Use in Rstest + +If your runner already provides a `window` global via jsdom, you can load the shared Lynx test environment with: + +```js +import '@lynx-js/testing-environment/env/rstest'; +``` + ## Credits Thanks to: diff --git a/packages/testing-library/testing-environment/etc/testing-environment.api.md b/packages/testing-library/testing-environment/etc/testing-environment.api.md index 5e78499041..53b5f861c9 100644 --- a/packages/testing-library/testing-environment/etc/testing-environment.api.md +++ b/packages/testing-library/testing-environment/etc/testing-environment.api.md @@ -4,8 +4,6 @@ ```ts -import { JSDOM } from 'jsdom'; - // @public export type ElementTree = ReturnType; @@ -48,6 +46,7 @@ export const initElementTree: () => { __AddDataset(e: LynxElement, key: string, value: string): void; __SetDataset(e: LynxElement, dataset: any): void; __SetGestureDetector(e: LynxElement, id: number, type: number, config: any, relationMap: Record): void; + __RemoveGestureDetector(e: LynxElement, id: number): void; __GetDataset(e: LynxElement): DOMStringMap; __RemoveElement(parent: LynxElement, child: LynxElement): void; __InsertElementBefore(parent: LynxElement, child: LynxElement, ref?: LynxElement | undefined): void; @@ -73,6 +72,13 @@ export const initElementTree: () => { __GetElementByUniqueId(uniqueId: number): LynxElement | undefined; }; +// @public +export function installLynxTestingEnv(target: typeof globalThis & { + lynxEnv?: LynxEnv; + lynxTestingEnv?: LynxTestingEnv; + Node?: typeof Node; +}, env: LynxEnv): void; + // @public export interface LynxElement extends HTMLElement { cssId?: string; @@ -87,6 +93,12 @@ export interface LynxElement extends HTMLElement { parentNode: LynxElement; } +// @public +export interface LynxEnv { + // (undocumented) + window: Window & typeof globalThis; +} + // @public export interface LynxGlobalThis { // (undocumented) @@ -96,14 +108,14 @@ export interface LynxGlobalThis { // @public export class LynxTestingEnv { - constructor(jsdom?: JSDOM); + constructor(env?: LynxEnv); backgroundThread: LynxGlobalThis; // (undocumented) clearGlobal(): void; // (undocumented) - injectGlobals(): void; + env: LynxEnv; // (undocumented) - jsdom: JSDOM; + injectGlobals(): void; mainThread: LynxGlobalThis & ElementTreeGlobals; // (undocumented) reset(): void; @@ -116,4 +128,11 @@ export class LynxTestingEnv { // @public (undocumented) export type PickUnderscoreKeys = Pick>; +// @public +export function uninstallLynxTestingEnv(target: typeof globalThis & { + lynxEnv?: LynxEnv; + lynxTestingEnv?: LynxTestingEnv; + Node?: typeof Node; +}): void; + ``` diff --git a/packages/testing-library/testing-environment/package.json b/packages/testing-library/testing-environment/package.json index 9e044c0873..7ae99d6abc 100644 --- a/packages/testing-library/testing-environment/package.json +++ b/packages/testing-library/testing-environment/package.json @@ -30,6 +30,12 @@ "import": "./dist/env/vitest/index.js", "require": "./dist/env/vitest/index.cjs", "default": "./dist/env/vitest/index.js" + }, + "./env/rstest": { + "types": "./dist/env/rstest/index.d.ts", + "import": "./dist/env/rstest/index.js", + "require": "./dist/env/rstest/index.cjs", + "default": "./dist/env/rstest/index.js" } }, "main": "./dist/index.cjs", @@ -39,6 +45,9 @@ "*": { "env/vitest": [ "./dist/env/vitest/index.d.ts" + ], + "env/rstest": [ + "./dist/env/rstest/index.d.ts" ] } }, @@ -50,6 +59,7 @@ "api-extractor": "api-extractor run --verbose", "build": "rslib build", "dev": "rslib build --watch", + "rstest": "rstest", "test": "vitest" }, "devDependencies": { diff --git a/packages/testing-library/testing-environment/rstest.config.ts b/packages/testing-library/testing-environment/rstest.config.ts new file mode 100644 index 0000000000..107b5a06dd --- /dev/null +++ b/packages/testing-library/testing-environment/rstest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from '@rstest/core'; + +export default defineConfig({ + testEnvironment: 'jsdom', + setupFiles: [ + require.resolve('./src/setupFiles/rstest.js'), + ], + globals: true, + resolve: { + alias: { + // Allow the shared test files to keep importing from `vitest`. + vitest: require.resolve('./vitest-polyfill.cjs'), + }, + }, + include: ['src/**/*.test.{js,jsx,ts,tsx}'], +}); diff --git a/packages/testing-library/testing-environment/src/env/rstest/index.ts b/packages/testing-library/testing-environment/src/env/rstest/index.ts new file mode 100644 index 0000000000..b4759c1c42 --- /dev/null +++ b/packages/testing-library/testing-environment/src/env/rstest/index.ts @@ -0,0 +1,3 @@ +import { installLynxTestingEnv } from '../../index.js'; + +installLynxTestingEnv(globalThis, { window }); diff --git a/packages/testing-library/testing-environment/src/env/vitest/index.ts b/packages/testing-library/testing-environment/src/env/vitest/index.ts index e5836eab90..fdd1c9d925 100644 --- a/packages/testing-library/testing-environment/src/env/vitest/index.ts +++ b/packages/testing-library/testing-environment/src/env/vitest/index.ts @@ -1,6 +1,5 @@ -import { builtinEnvironments, Environment } from 'vitest/environments'; -import { LynxTestingEnv } from '@lynx-js/testing-environment'; -import { JSDOM } from 'jsdom'; +import { builtinEnvironments, type Environment } from 'vitest/environments'; +import { installLynxTestingEnv, uninstallLynxTestingEnv } from '../../index.js'; const env = { name: 'lynxTestingEnv', @@ -9,17 +8,17 @@ const env = { const fakeGlobal: { jsdom?: any; } = {}; - await builtinEnvironments.jsdom.setup(fakeGlobal, {}); + const jsdomEnvironment = await builtinEnvironments.jsdom.setup( + fakeGlobal, + {}, + ); - const lynxTestingEnv = new LynxTestingEnv(fakeGlobal.jsdom as JSDOM); - global.lynxTestingEnv = lynxTestingEnv; - global.Node = lynxTestingEnv.jsdom.window.Node; + installLynxTestingEnv(global, fakeGlobal.jsdom); return { - teardown(global) { - delete global.lynxTestingEnv; - delete global.jsdom; - delete global.Node; + async teardown(global) { + await jsdomEnvironment.teardown(fakeGlobal); + uninstallLynxTestingEnv(global); }, }; }, diff --git a/packages/testing-library/testing-environment/src/index.ts b/packages/testing-library/testing-environment/src/index.ts index 8d2aa611ec..bb729f1aff 100644 --- a/packages/testing-library/testing-environment/src/index.ts +++ b/packages/testing-library/testing-environment/src/index.ts @@ -5,15 +5,23 @@ * notably the {@link https://lynxjs.org/api/engine/element-api | Element PAPI} and {@link https://lynxjs.org/guide/spec#dual-threaded-model | Dual-threaded Model} for use with Node.js. */ -import EventEmitter from 'events'; -import { JSDOM } from 'jsdom'; +import EventEmitter from 'node:events'; import { createGlobalThis, LynxGlobalThis } from './lynx/GlobalThis.js'; import { initElementTree, type LynxElement } from './lynx/ElementPAPI.js'; -import { Console } from 'console'; import { GlobalEventEmitter } from './lynx/GlobalEventEmitter.js'; export { initElementTree } from './lynx/ElementPAPI.js'; export type { LynxElement } from './lynx/ElementPAPI.js'; export type { LynxGlobalThis } from './lynx/GlobalThis.js'; + +/** + * The host environment used to initialize `LynxTestingEnv`. + * + * @public + */ +export interface LynxEnv { + window: Window & typeof globalThis; +} + /** * @public * The lynx element tree @@ -36,6 +44,7 @@ export type PickUnderscoreKeys = Pick>; export type ElementTreeGlobals = PickUnderscoreKeys; declare global { + var lynxEnv: LynxEnv; var lynxTestingEnv: LynxTestingEnv; var elementTree: ElementTree; var __JS__: boolean; @@ -55,6 +64,41 @@ declare global { function onInitWorkletRuntime(): void; } +/** + * Installs a `LynxTestingEnv` instance and its required globals onto a target. + * + * @public + */ +export function installLynxTestingEnv( + target: typeof globalThis & { + lynxEnv?: LynxEnv; + lynxTestingEnv?: LynxTestingEnv; + Node?: typeof Node; + }, + env: LynxEnv, +): void { + target.lynxEnv = env; + target.lynxTestingEnv = new LynxTestingEnv(env); + target.Node = env.window.Node; +} + +/** + * Removes the globals installed by `installLynxTestingEnv`. + * + * @public + */ +export function uninstallLynxTestingEnv( + target: typeof globalThis & { + lynxEnv?: LynxEnv; + lynxTestingEnv?: LynxTestingEnv; + Node?: typeof Node; + }, +): void { + delete target.lynxTestingEnv; + delete target.lynxEnv; + delete target.Node; +} + function __injectElementApi(target?: any) { const elementTree = initElementTree(); target.elementTree = elementTree; @@ -212,18 +256,6 @@ function createPolyfills() { }; } -function createPreconfiguredConsole() { - const console = new Console( - process.stdout, - process.stderr, - ); - console.profile = () => {}; - console.profileEnd = () => {}; - // @ts-expect-error Lynx has console.alog - console.alog = () => {}; - return console; -} - function injectMainThreadGlobals(target?: any, polyfills?: any) { __injectElementApi(target); @@ -301,7 +333,10 @@ function injectMainThreadGlobals(target?: any, polyfills?: any) { target.requestAnimationFrame = setTimeout; target.cancelAnimationFrame = clearTimeout; - target.console = createPreconfiguredConsole(); + target.console.profile = console.profile = () => {}; + target.console.profileEnd = console.profileEnd = () => {}; + // @ts-expect-error Lynx has console.alog + target.console.alog = console.alog = () => {}; target.__LoadLepusChunk = __LoadLepusChunk; @@ -407,7 +442,7 @@ function injectBackgroundThreadGlobals(target?: any, polyfills?: any) { }); }, select: function(selector: string) { - const el = lynxTestingEnv.jsdom.window.document.querySelector( + const el = lynxTestingEnv.env.window.document.querySelector( selector, ) as LynxElement; if (!el) { @@ -449,7 +484,10 @@ function injectBackgroundThreadGlobals(target?: any, polyfills?: any) { target.requestAnimationFrame = setTimeout; target.cancelAnimationFrame = clearTimeout; - target.console = createPreconfiguredConsole(); + target.console.profile = console.profile = () => {}; + target.console.profileEnd = console.profileEnd = () => {}; + // @ts-expect-error Lynx has console.alog + target.console.alog = console.alog = () => {}; // TODO: user-configurable target.SystemInfo = { @@ -476,8 +514,9 @@ function injectBackgroundThreadGlobals(target?: any, polyfills?: any) { * * ```ts * import { LynxTestingEnv } from '@lynx-js/testing-environment'; + * import { JSDOM } from 'jsdom'; * - * const lynxTestingEnv = new LynxTestingEnv(new JSDOM()); + * const lynxTestingEnv = new LynxTestingEnv({ window: new JSDOM().window }); * * lynxTestingEnv.switchToMainThread(); * // use the main thread Element PAPI @@ -498,8 +537,9 @@ export class LynxTestingEnv { * * ```ts * import { LynxTestingEnv } from '@lynx-js/testing-environment'; + * import { JSDOM } from 'jsdom'; * - * const lynxTestingEnv = new LynxTestingEnv(new JSDOM()); + * const lynxTestingEnv = new LynxTestingEnv({ window: new JSDOM().window }); * * lynxTestingEnv.switchToBackgroundThread(); * // use the background thread global object @@ -514,8 +554,9 @@ export class LynxTestingEnv { * * ```ts * import { LynxTestingEnv } from '@lynx-js/testing-environment'; + * import { JSDOM } from 'jsdom'; * - * const lynxTestingEnv = new LynxTestingEnv(new JSDOM()); + * const lynxTestingEnv = new LynxTestingEnv({ window: new JSDOM().window }); * * lynxTestingEnv.switchToMainThread(); * // use the main thread global object @@ -525,14 +566,15 @@ export class LynxTestingEnv { * ``` */ mainThread: LynxGlobalThis & ElementTreeGlobals; - jsdom: JSDOM; - constructor(jsdom?: JSDOM) { + env: LynxEnv; + constructor(env?: LynxEnv) { // Prefer explicit instance; fall back to test runner-provided global. - this.jsdom = jsdom ?? global.jsdom; - if (!this.jsdom) { + this.env = (env ?? global.lynxEnv) as LynxEnv; + if (!this.env) { throw new Error( - 'LynxTestingEnv requires a JSDOM instance. Pass one to the constructor, ' - + 'or ensure your test runner sets global.jsdom (e.g., via a setup file).', + 'LynxTestingEnv requires an object with a jsdom-like `window`. Pass ' + + '`{ window }` to the constructor, or ensure your test runner sets ' + + 'global.lynxEnv to that shape (e.g., via a setup file).', ); } @@ -540,13 +582,13 @@ export class LynxTestingEnv { this.mainThread = createGlobalThis() as any; const globalPolyfills = { - console: this.jsdom.window['console'], + console: this.env.window['console'], // `Event` is required by `fireEvent` in `@testing-library/dom` - Event: this.jsdom.window.Event, + Event: this.env.window.Event, // `window` is required by `getDocument` in `@testing-library/dom` - window: this.jsdom.window, + window: this.env.window, // `document` is required by `screen` in `@testing-library/dom` - document: this.jsdom.window.document, + document: this.env.window.document, }; Object.assign( diff --git a/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts b/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts index 5503194804..bc9cc1de45 100644 --- a/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts +++ b/packages/testing-library/testing-environment/src/lynx/ElementPAPI.ts @@ -80,12 +80,12 @@ export const initElementTree = () => { const page = this.__CreateElement('page', parentComponentUniqueId); this.root = page; document.body.innerHTML = ''; - lynxTestingEnv.jsdom.window.document.body.appendChild(page); + lynxTestingEnv.env.window.document.body.appendChild(page); return page; } __CreateRawText(text: string): LynxElement { - const element = lynxTestingEnv.jsdom.window.document + const element = lynxTestingEnv.env.window.document .createTextNode( text, ) as unknown as LynxElement; @@ -110,7 +110,7 @@ export const initElementTree = () => { return this.__CreateRawText(''); } - const element = lynxTestingEnv.jsdom.window.document + const element = lynxTestingEnv.env.window.document .createElement( tag, ) as LynxElement; @@ -315,6 +315,12 @@ export const initElementTree = () => { }; } + __RemoveGestureDetector(e: LynxElement, id: number) { + if (e.gesture?.['id'] === id) { + delete e.gesture; + } + } + __GetDataset(e: LynxElement) { return e.dataset; } diff --git a/packages/testing-library/testing-environment/src/setupFiles/rstest.js b/packages/testing-library/testing-environment/src/setupFiles/rstest.js new file mode 100644 index 0000000000..41d82b0219 --- /dev/null +++ b/packages/testing-library/testing-environment/src/setupFiles/rstest.js @@ -0,0 +1 @@ +import '../env/rstest/index.js'; diff --git a/packages/testing-library/testing-environment/tsconfig.json b/packages/testing-library/testing-environment/tsconfig.json index 88f6924cbf..75db74d64e 100644 --- a/packages/testing-library/testing-environment/tsconfig.json +++ b/packages/testing-library/testing-environment/tsconfig.json @@ -5,6 +5,8 @@ "noImplicitAny": false, "isolatedDeclarations": false, "rootDir": "src", + "outDir": "dist", + "declarationDir": "dist", }, "include": [ diff --git a/packages/testing-library/testing-environment/vitest-polyfill.cjs b/packages/testing-library/testing-environment/vitest-polyfill.cjs new file mode 100644 index 0000000000..647f2e4a8d --- /dev/null +++ b/packages/testing-library/testing-environment/vitest-polyfill.cjs @@ -0,0 +1,6 @@ +// in order to make our test case work for +// both vitest and rstest, we need to alias +// `vitest` to `@rstest/core` + +global['@rstest/core'].vi = global['@rstest/core'].rs; +module.exports = global['@rstest/core']; diff --git a/packages/use-sync-external-store/test/use-synx-external-store.test.ts b/packages/use-sync-external-store/test/use-sync-external-store.test.ts similarity index 98% rename from packages/use-sync-external-store/test/use-synx-external-store.test.ts rename to packages/use-sync-external-store/test/use-sync-external-store.test.ts index a5298ca6e0..a1b8a66f97 100644 --- a/packages/use-sync-external-store/test/use-synx-external-store.test.ts +++ b/packages/use-sync-external-store/test/use-sync-external-store.test.ts @@ -180,7 +180,9 @@ describe('useSyncExternalStoreWithSelector', () => { const store = createExternalStore({ items: ['A', 'B'], }); - const shallowEqualArray = (a: T[], b: T[]) => { + // dprint-ignore the comma is required to avoid + // ts treat T as a JSX element + const shallowEqualArray = (a: T[], b: T[]) => { if (a.length !== b.length) { return false; } diff --git a/packages/use-sync-external-store/vitest.config.ts b/packages/use-sync-external-store/vitest.config.ts index 94b3576d8e..9f657d6936 100644 --- a/packages/use-sync-external-store/vitest.config.ts +++ b/packages/use-sync-external-store/vitest.config.ts @@ -1,16 +1,15 @@ -import { defineProject, mergeConfig } from 'vitest/config'; +import { defineProject } from 'vitest/config'; import type { UserWorkspaceConfig } from 'vitest/config'; -import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config'; - -const defaultConfig = await createVitestConfig(); +import { vitestTestingLibraryPlugin } from '@lynx-js/react/testing-library/plugins'; const config: UserWorkspaceConfig = defineProject({ + plugins: [ + vitestTestingLibraryPlugin(), + ], test: { name: 'use-sync-external-store', }, }); -const mergedConfig: UserWorkspaceConfig = mergeConfig(defaultConfig, config); - -export default mergedConfig; +export default config; diff --git a/packages/web-platform/web-core/binary/client/client.d.ts b/packages/web-platform/web-core/binary/client/client.d.ts index 3557cc760a..9d23d7e787 100644 --- a/packages/web-platform/web-core/binary/client/client.d.ts +++ b/packages/web-platform/web-core/binary/client/client.d.ts @@ -22,15 +22,16 @@ export class MainThreadWasmContext { add_dataset(unique_id: number, key: any, value: any): void; add_run_worklet_event(unique_id: number, event_type: string, event_name: string, event_handler_identifier?: any | null): void; common_event_handler(event: any, bubble_unique_id_path: Uint32Array, event_name: string, is_bubble: boolean): void; - create_element_common(parent_component_unique_id: number, dom: HTMLElement, css_id?: number | null, component_id?: string | null): number; + create_element_common(parent_component_unique_id: number, dom: HTMLElement, dom_ref: WeakRef, css_id?: number | null, component_id?: string | null): number; dispatch_event_by_path(bubble_unique_id_path: Uint32Array, event_name: string, is_capture: boolean, serialized_event: any): boolean; dispatch_global_bind_event(bubble_unique_id_path: Uint32Array, event_name: string, serialized_event: any): void; + gc(): void; get_component_id(unique_id: number): string | undefined; get_config(unique_id: number): object; get_css_id_by_unique_id(unique_id: number): number | undefined; get_data_by_key(unique_id: number, key: string): any; get_dataset(unique_id: number): object; - get_dom_by_unique_id(unique_id: number): HTMLElement | undefined; + get_dom_by_unique_id(unique_id: number): WeakRef | undefined; get_element_config(unique_id: number): object | undefined; get_event(unique_id: number, event_name: string, event_type: string): any; get_events(unique_id: number): EventInfo[]; @@ -201,9 +202,10 @@ export interface InitOutput { readonly mainthreadwasmcontext_add_dataset: (a: number, b: number, c: any, d: any) => [number, number]; readonly mainthreadwasmcontext_add_run_worklet_event: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; readonly mainthreadwasmcontext_common_event_handler: (a: number, b: any, c: number, d: number, e: number, f: number, g: number) => void; - readonly mainthreadwasmcontext_create_element_common: (a: number, b: number, c: any, d: number, e: number, f: number) => number; + readonly mainthreadwasmcontext_create_element_common: (a: number, b: number, c: any, d: any, e: number, f: number, g: number) => number; readonly mainthreadwasmcontext_dispatch_event_by_path: (a: number, b: number, c: number, d: number, e: number, f: number, g: any) => number; readonly mainthreadwasmcontext_dispatch_global_bind_event: (a: number, b: number, c: number, d: number, e: number, f: any) => void; + readonly mainthreadwasmcontext_gc: (a: number) => void; readonly mainthreadwasmcontext_get_component_id: (a: number, b: number) => [number, number, number, number]; readonly mainthreadwasmcontext_get_config: (a: number, b: number) => [number, number, number]; readonly mainthreadwasmcontext_get_css_id_by_unique_id: (a: number, b: number) => number; diff --git a/packages/web-platform/web-core/binary/client/client_bg.wasm.d.ts b/packages/web-platform/web-core/binary/client/client_bg.wasm.d.ts index 05b52af61f..350a3ae89a 100644 --- a/packages/web-platform/web-core/binary/client/client_bg.wasm.d.ts +++ b/packages/web-platform/web-core/binary/client/client_bg.wasm.d.ts @@ -23,9 +23,10 @@ export const mainthreadwasmcontext_add_cross_thread_event: (a: number, b: number export const mainthreadwasmcontext_add_dataset: (a: number, b: number, c: any, d: any) => [number, number]; export const mainthreadwasmcontext_add_run_worklet_event: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; export const mainthreadwasmcontext_common_event_handler: (a: number, b: any, c: number, d: number, e: number, f: number, g: number) => void; -export const mainthreadwasmcontext_create_element_common: (a: number, b: number, c: any, d: number, e: number, f: number) => number; +export const mainthreadwasmcontext_create_element_common: (a: number, b: number, c: any, d: any, e: number, f: number, g: number) => number; export const mainthreadwasmcontext_dispatch_event_by_path: (a: number, b: number, c: number, d: number, e: number, f: number, g: any) => number; export const mainthreadwasmcontext_dispatch_global_bind_event: (a: number, b: number, c: number, d: number, e: number, f: any) => void; +export const mainthreadwasmcontext_gc: (a: number) => void; export const mainthreadwasmcontext_get_component_id: (a: number, b: number) => [number, number, number, number]; export const mainthreadwasmcontext_get_config: (a: number, b: number) => [number, number, number]; export const mainthreadwasmcontext_get_css_id_by_unique_id: (a: number, b: number) => number; diff --git a/packages/web-platform/web-core/binary/client_legacy/client.d.ts b/packages/web-platform/web-core/binary/client_legacy/client.d.ts index 942ed1e0dc..7fbe9cbda5 100644 --- a/packages/web-platform/web-core/binary/client_legacy/client.d.ts +++ b/packages/web-platform/web-core/binary/client_legacy/client.d.ts @@ -22,15 +22,16 @@ export class MainThreadWasmContext { add_dataset(unique_id: number, key: any, value: any): void; add_run_worklet_event(unique_id: number, event_type: string, event_name: string, event_handler_identifier?: any | null): void; common_event_handler(event: any, bubble_unique_id_path: Uint32Array, event_name: string, is_bubble: boolean): void; - create_element_common(parent_component_unique_id: number, dom: HTMLElement, css_id?: number | null, component_id?: string | null): number; + create_element_common(parent_component_unique_id: number, dom: HTMLElement, dom_ref: WeakRef, css_id?: number | null, component_id?: string | null): number; dispatch_event_by_path(bubble_unique_id_path: Uint32Array, event_name: string, is_capture: boolean, serialized_event: any): boolean; dispatch_global_bind_event(bubble_unique_id_path: Uint32Array, event_name: string, serialized_event: any): void; + gc(): void; get_component_id(unique_id: number): string | undefined; get_config(unique_id: number): object; get_css_id_by_unique_id(unique_id: number): number | undefined; get_data_by_key(unique_id: number, key: string): any; get_dataset(unique_id: number): object; - get_dom_by_unique_id(unique_id: number): HTMLElement | undefined; + get_dom_by_unique_id(unique_id: number): WeakRef | undefined; get_element_config(unique_id: number): object | undefined; get_event(unique_id: number, event_name: string, event_type: string): any; get_events(unique_id: number): EventInfo[]; @@ -201,9 +202,10 @@ export interface InitOutput { readonly mainthreadwasmcontext_add_dataset: (a: number, b: number, c: number, d: number, e: number) => void; readonly mainthreadwasmcontext_add_run_worklet_event: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; readonly mainthreadwasmcontext_common_event_handler: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; - readonly mainthreadwasmcontext_create_element_common: (a: number, b: number, c: number, d: number, e: number, f: number) => number; + readonly mainthreadwasmcontext_create_element_common: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number; readonly mainthreadwasmcontext_dispatch_event_by_path: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number; readonly mainthreadwasmcontext_dispatch_global_bind_event: (a: number, b: number, c: number, d: number, e: number, f: number) => void; + readonly mainthreadwasmcontext_gc: (a: number) => void; readonly mainthreadwasmcontext_get_component_id: (a: number, b: number, c: number) => void; readonly mainthreadwasmcontext_get_config: (a: number, b: number, c: number) => void; readonly mainthreadwasmcontext_get_css_id_by_unique_id: (a: number, b: number) => number; diff --git a/packages/web-platform/web-core/binary/client_legacy/client_bg.wasm.d.ts b/packages/web-platform/web-core/binary/client_legacy/client_bg.wasm.d.ts index 052e560b78..89bf0a44ab 100644 --- a/packages/web-platform/web-core/binary/client_legacy/client_bg.wasm.d.ts +++ b/packages/web-platform/web-core/binary/client_legacy/client_bg.wasm.d.ts @@ -23,9 +23,10 @@ export const mainthreadwasmcontext_add_cross_thread_event: (a: number, b: number export const mainthreadwasmcontext_add_dataset: (a: number, b: number, c: number, d: number, e: number) => void; export const mainthreadwasmcontext_add_run_worklet_event: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; export const mainthreadwasmcontext_common_event_handler: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void; -export const mainthreadwasmcontext_create_element_common: (a: number, b: number, c: number, d: number, e: number, f: number) => number; +export const mainthreadwasmcontext_create_element_common: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number; export const mainthreadwasmcontext_dispatch_event_by_path: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number; export const mainthreadwasmcontext_dispatch_global_bind_event: (a: number, b: number, c: number, d: number, e: number, f: number) => void; +export const mainthreadwasmcontext_gc: (a: number) => void; export const mainthreadwasmcontext_get_component_id: (a: number, b: number, c: number) => void; export const mainthreadwasmcontext_get_config: (a: number, b: number, c: number) => void; export const mainthreadwasmcontext_get_css_id_by_unique_id: (a: number, b: number) => number; diff --git a/packages/web-platform/web-core/src/js_binding/mts_js_binding.rs b/packages/web-platform/web-core/src/js_binding/mts_js_binding.rs index 9bfdb2eb2e..ba9f93a853 100644 --- a/packages/web-platform/web-core/src/js_binding/mts_js_binding.rs +++ b/packages/web-platform/web-core/src/js_binding/mts_js_binding.rs @@ -36,24 +36,39 @@ extern "C" { #[wasm_bindgen(method, js_name = "addEventListener")] pub fn add_event_listener(this: &RustMainthreadContextBinding, event_name: &str); - #[wasm_bindgen(method, js_name = "enableElementEvent")] + #[wasm_bindgen(method, catch, js_name = "enableElementEvent")] pub fn enable_element_event( this: &RustMainthreadContextBinding, - element: &web_sys::HtmlElement, + element: &js_sys::WeakRef, event_name: &str, - ); + ) -> Result<(), JsValue>; - #[wasm_bindgen(method, js_name = "disableElementEvent")] + #[wasm_bindgen(method, catch, js_name = "disableElementEvent")] pub fn disable_element_event( this: &RustMainthreadContextBinding, - element: &web_sys::HtmlElement, + element: &js_sys::WeakRef, event_name: &str, - ); + ) -> Result<(), JsValue>; - #[wasm_bindgen(method, js_name = "getClassList")] + #[wasm_bindgen(method, catch, js_name = "getClassList")] pub fn get_class_name_list( this: &RustMainthreadContextBinding, - element: &web_sys::HtmlElement, - ) -> Vec; + element: &js_sys::WeakRef, + ) -> Result, JsValue>; + + #[wasm_bindgen(method, catch, js_name = "setAttribute")] + pub fn set_attribute( + this: &RustMainthreadContextBinding, + element: &js_sys::WeakRef, + name: &str, + value: &str, + ) -> Result<(), JsValue>; + + #[wasm_bindgen(method, catch, js_name = "removeAttribute")] + pub fn remove_attribute( + this: &RustMainthreadContextBinding, + element: &js_sys::WeakRef, + name: &str, + ) -> Result<(), JsValue>; } diff --git a/packages/web-platform/web-core/src/main_thread/client/element_apis/event_apis.rs b/packages/web-platform/web-core/src/main_thread/client/element_apis/event_apis.rs index b05c571e61..06415fa99c 100644 --- a/packages/web-platform/web-core/src/main_thread/client/element_apis/event_apis.rs +++ b/packages/web-platform/web-core/src/main_thread/client/element_apis/event_apis.rs @@ -66,13 +66,13 @@ impl MainThreadWasmContext { if should_enable { if let Some(element) = self.unique_id_to_dom_map.get(&unique_id) { - self + let _ = self .mts_binding .enable_element_event(element, event_name_str); } } else if should_disable { if let Some(element) = self.unique_id_to_dom_map.get(&unique_id) { - self + let _ = self .mts_binding .disable_element_event(element, event_name_str); } @@ -122,13 +122,13 @@ impl MainThreadWasmContext { if should_enable { if let Some(element) = self.unique_id_to_dom_map.get(&unique_id) { - self + let _ = self .mts_binding .enable_element_event(element, event_name_str); } } else if should_disable { if let Some(element) = self.unique_id_to_dom_map.get(&unique_id) { - self + let _ = self .mts_binding .disable_element_event(element, event_name_str); } diff --git a/packages/web-platform/web-core/src/main_thread/client/element_apis/style_apis.rs b/packages/web-platform/web-core/src/main_thread/client/element_apis/style_apis.rs index 0f4246813d..34bf3795a9 100644 --- a/packages/web-platform/web-core/src/main_thread/client/element_apis/style_apis.rs +++ b/packages/web-platform/web-core/src/main_thread/client/element_apis/style_apis.rs @@ -23,12 +23,22 @@ impl MainThreadWasmContext { { let element = self.unique_id_to_dom_map.get(&unique_id).expect_throw("El"); if let Some(entry_name) = &entry_name { - let _ = element.set_attribute(constants::LYNX_ENTRY_NAME_ATTRIBUTE, entry_name); + let _ = self.mts_binding.set_attribute( + element, + constants::LYNX_ENTRY_NAME_ATTRIBUTE, + entry_name, + ); } if css_id != 0 { - let _ = element.set_attribute(constants::CSS_ID_ATTRIBUTE, &css_id.to_string()); + let _ = self.mts_binding.set_attribute( + element, + constants::CSS_ID_ATTRIBUTE, + &css_id.to_string(), + ); } else { - let _ = element.remove_attribute(constants::CSS_ID_ATTRIBUTE); + let _ = self + .mts_binding + .remove_attribute(element, constants::CSS_ID_ATTRIBUTE); } { let element_data_cell = self.get_element_data_by_unique_id(unique_id).unwrap_throw(); @@ -54,7 +64,10 @@ impl MainThreadWasmContext { self.style_manager.update_css_og_style( unique_id, element_data.css_id, - self.mts_binding.get_class_name_list(element), + self + .mts_binding + .get_class_name_list(element) + .unwrap_or_default(), entry_name, )?; Ok(()) diff --git a/packages/web-platform/web-core/src/main_thread/client/main_thread_context.rs b/packages/web-platform/web-core/src/main_thread/client/main_thread_context.rs index d67f7e8c03..7b07b6ad28 100644 --- a/packages/web-platform/web-core/src/main_thread/client/main_thread_context.rs +++ b/packages/web-platform/web-core/src/main_thread/client/main_thread_context.rs @@ -17,7 +17,7 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen] pub struct MainThreadWasmContext { pub(super) unique_id_to_element_map: Vec>>>>, - pub(super) unique_id_to_dom_map: FnvHashMap, + pub(super) unique_id_to_dom_map: FnvHashMap, pub(super) timing_flags: Vec, pub(super) enabled_events: FnvHashSet, @@ -78,6 +78,7 @@ impl MainThreadWasmContext { self: &mut MainThreadWasmContext, parent_component_unique_id: usize, dom: web_sys::HtmlElement, + dom_ref: js_sys::WeakRef, css_id: Option, component_id: Option, ) -> usize { @@ -111,11 +112,11 @@ impl MainThreadWasmContext { self .unique_id_to_element_map .push(Some(Rc::new(RefCell::new(element_data)))); - self.unique_id_to_dom_map.insert(unique_id, dom.clone()); + self.unique_id_to_dom_map.insert(unique_id, dom_ref); unique_id } - pub fn get_dom_by_unique_id(&self, unique_id: usize) -> Option { + pub fn get_dom_by_unique_id(&self, unique_id: usize) -> Option { self.unique_id_to_dom_map.get(&unique_id).cloned() } @@ -145,10 +146,18 @@ impl MainThreadWasmContext { .map(|element_data_cell| element_data_cell.borrow().css_id) } - // pub fn gc(&mut self) { - // self.unique_id_to_element_map.retain(|_, value| { - // let dom = value.get_dom(); - // dom.is_connected() - // }); - // } + pub fn gc(&mut self) { + let mut ids_to_remove = Vec::new(); + for (unique_id, weak_ref) in self.unique_id_to_dom_map.iter() { + if weak_ref.deref().is_none() { + ids_to_remove.push(*unique_id); + } + } + for id in ids_to_remove { + self.unique_id_to_dom_map.remove(&id); + if let Some(element_data) = self.unique_id_to_element_map.get_mut(id) { + *element_data = None; + } + } + } } diff --git a/packages/web-platform/web-core/tests/element-apis.spec.ts b/packages/web-platform/web-core/tests/element-apis.spec.ts index 2797e3fb26..ebcea8431d 100644 --- a/packages/web-platform/web-core/tests/element-apis.spec.ts +++ b/packages/web-platform/web-core/tests/element-apis.spec.ts @@ -1576,9 +1576,9 @@ describe('Element APIs', () => { mtsGlobalThis.__AppendElement(root, element); const spy = vi.spyOn(mtsBinding, 'getClassList'); - const classes = mtsBinding.getClassList(element); + const classes = mtsBinding.getClassList(new WeakRef(element)); - expect(spy).toHaveBeenCalledWith(element); + expect(spy).toHaveBeenCalledWith(expect.any(WeakRef)); expect(classes).toEqual(expect.arrayContaining(['foo', 'bar'])); expect(classes.length).toBe(2); }); diff --git a/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/WASMJSBinding.ts b/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/WASMJSBinding.ts index 539b67d46e..2d169e5cbd 100644 --- a/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/WASMJSBinding.ts +++ b/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/WASMJSBinding.ts @@ -60,13 +60,19 @@ export class WASMJSBinding implements RustMainthreadContextBinding { } getClassList( - element: HTMLElement, + elementRef: WeakRef, ): string[] { - return [...(element.classList as unknown as string[])]; + const element = elementRef.deref(); + if (element) { + return [...(element.classList as unknown as string[])]; + } + return []; } getElementByUniqueId(uniqueId: number): HTMLElement | undefined { - return this.wasmContext?.get_dom_by_unique_id(uniqueId); + return this.wasmContext?.get_dom_by_unique_id(uniqueId)?.deref() as + | HTMLElement + | undefined; } getElementByComponentId( @@ -203,17 +209,33 @@ export class WASMJSBinding implements RustMainthreadContextBinding { ); } - enableElementEvent(element: HTMLElement, eventName: string) { + enableElementEvent(elementRef: WeakRef, eventName: string) { + const element = elementRef.deref(); if (element) { // @ts-expect-error element.enableEvent?.(LynxEventNameToW3cCommon[eventName] ?? eventName); } } - disableElementEvent(element: HTMLElement, eventName: string) { + disableElementEvent(elementRef: WeakRef, eventName: string) { + const element = elementRef.deref(); if (element) { // @ts-expect-error element.disableEvent?.(LynxEventNameToW3cCommon[eventName] ?? eventName); } } + + setAttribute(elementRef: WeakRef, name: string, value: string) { + const element = elementRef.deref(); + if (element) { + element.setAttribute(name, value); + } + } + + removeAttribute(elementRef: WeakRef, name: string) { + const element = elementRef.deref(); + if (element) { + element.removeAttribute(name); + } + } } diff --git a/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts b/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts index 27ab251815..a5b3bc549f 100644 --- a/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts +++ b/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts @@ -125,7 +125,9 @@ export function createElementAPI( ); } if (eventName === 'uiappear' || eventName === 'uidisappear') { - const element = wasmContext.get_dom_by_unique_id(uniqueId); + const element = wasmContext.get_dom_by_unique_id(uniqueId)?.deref() as + | HTMLElement + | undefined; if (element) { mtsBinding.markExposureRelatedElementByUniqueId( element, @@ -140,6 +142,7 @@ export function createElementAPI( dom[uniqueIdSymbol] = wasmContext.create_element_common( parentComponentUniqueId, dom, + new WeakRef(dom), ); return dom; }, @@ -148,6 +151,7 @@ export function createElementAPI( dom[uniqueIdSymbol] = wasmContext.create_element_common( parentComponentUniqueId, dom, + new WeakRef(dom), ); return dom; }, @@ -156,13 +160,18 @@ export function createElementAPI( dom[uniqueIdSymbol] = wasmContext.create_element_common( parentComponentUniqueId, dom, + new WeakRef(dom), ); return dom; }, __CreateRawText(text) { const dom = document.createElement('raw-text') as DecoratedHTMLElement; dom.setAttribute('text', text); - dom[uniqueIdSymbol] = wasmContext.create_element_common(-1, dom); + dom[uniqueIdSymbol] = wasmContext.create_element_common( + -1, + dom, + new WeakRef(dom), + ); return dom; }, __CreateScrollView(parentComponentUniqueId) { @@ -171,6 +180,7 @@ export function createElementAPI( dom[uniqueIdSymbol] = wasmContext.create_element_common( parentComponentUniqueId, dom, + new WeakRef(dom), ); return dom; }, @@ -181,6 +191,7 @@ export function createElementAPI( dom[uniqueIdSymbol] = wasmContext.create_element_common( parentComponentUniqueId, dom, + new WeakRef(dom), ); return dom; }, @@ -195,6 +206,7 @@ export function createElementAPI( dom[uniqueIdSymbol] = wasmContext.create_element_common( parentComponentUniqueId, dom, + new WeakRef(dom), cssID, componentID, ); @@ -213,6 +225,7 @@ export function createElementAPI( dom[uniqueIdSymbol] = wasmContext.create_element_common( parentComponentUniqueId, dom, + new WeakRef(dom), ); return dom; }, @@ -223,6 +236,7 @@ export function createElementAPI( dom[uniqueIdSymbol] = wasmContext.create_element_common( parentComponentUniqueId, dom, + new WeakRef(dom), ); return dom; }, @@ -234,6 +248,7 @@ export function createElementAPI( dom[uniqueIdSymbol] = wasmContext.create_element_common( 0, dom, + new WeakRef(dom), cssID, componentID, ); @@ -420,7 +435,8 @@ export function createElementAPI( false, ) as number | undefined; if (typeof childSign === 'number') { - const childElement = wasmContext.get_dom_by_unique_id(childSign); + const childElement = wasmContext.get_dom_by_unique_id(childSign) + ?.deref() as HTMLElement | undefined; if (childElement) { const referenceNode = element.children[action.position]; if (referenceNode !== childElement) { @@ -591,6 +607,7 @@ export function createElementAPI( timingFlagsAll, pipelineId, ); + wasmContext.gc(); }); timingFlags.length = 0; const enabledExposureElements = [ diff --git a/packages/web-platform/web-core/ts/client/wasm.ts b/packages/web-platform/web-core/ts/client/wasm.ts index 0bcd857ad7..b7a189df57 100644 --- a/packages/web-platform/web-core/ts/client/wasm.ts +++ b/packages/web-platform/web-core/ts/client/wasm.ts @@ -4,6 +4,7 @@ * LICENSE file in the root directory of this source tree. */ import { referenceTypes, simd } from 'wasm-feature-detect'; +import type * as WasmInstanceType from '../../binary/client/client.js'; const isWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; const wasmLoaded = Promise.all([referenceTypes(), simd()]).then( @@ -57,7 +58,7 @@ const wasmLoaded = Promise.all([referenceTypes(), simd()]).then( ]); } }, -); +) as Promise<[typeof WasmInstanceType, WebAssembly.Module]>; export const [wasmInstance, wasmModule] = await wasmLoaded; if (!isWorker) { wasmInstance.initSync({ module: wasmModule! }); diff --git a/packages/web-platform/web-core/ts/types/IMtsBinding.ts b/packages/web-platform/web-core/ts/types/IMtsBinding.ts index 3dbac570bf..8ff09c5370 100644 --- a/packages/web-platform/web-core/ts/types/IMtsBinding.ts +++ b/packages/web-platform/web-core/ts/types/IMtsBinding.ts @@ -37,9 +37,17 @@ export interface RustMainthreadContextBinding { toEnable: boolean, ): void; - enableElementEvent(element: HTMLElement, eventName: string): void; + enableElementEvent(element: WeakRef, eventName: string): void; - disableElementEvent(element: HTMLElement, eventName: string): void; + disableElementEvent(element: WeakRef, eventName: string): void; - getClassList(element: HTMLElement): string[]; + getClassList(element: WeakRef): string[]; + + setAttribute( + element: WeakRef, + name: string, + value: string, + ): void; + + removeAttribute(element: WeakRef, name: string): void; } diff --git a/packages/webpack/css-extract-webpack-plugin/src/CssExtractRspackPlugin.ts b/packages/webpack/css-extract-webpack-plugin/src/CssExtractRspackPlugin.ts index 93a7b1e096..b118a6ee50 100644 --- a/packages/webpack/css-extract-webpack-plugin/src/CssExtractRspackPlugin.ts +++ b/packages/webpack/css-extract-webpack-plugin/src/CssExtractRspackPlugin.ts @@ -43,7 +43,7 @@ const require = createRequire(import.meta.url); * @public * * CssExtractRspackPlugin is the CSS extract plugin for Lynx. - * It works just like the {@link https://www.rspack.dev/plugins/rspack/css-extract-rspack-plugin.html | CssExtractRspackPlugin} in Web. + * It works just like the {@link https://rspack.rs/plugins/rspack/css-extract-rspack-plugin.html | CssExtractRspackPlugin} in Web. * * @example * ```js diff --git a/packages/webpack/react-webpack-plugin/etc/react-webpack-plugin.api.md b/packages/webpack/react-webpack-plugin/etc/react-webpack-plugin.api.md index 8db9b54706..e9bd1a2435 100644 --- a/packages/webpack/react-webpack-plugin/etc/react-webpack-plugin.api.md +++ b/packages/webpack/react-webpack-plugin/etc/react-webpack-plugin.api.md @@ -38,7 +38,7 @@ export class ReactWebpackPlugin { constructor(options?: ReactWebpackPluginOptions | undefined); apply(compiler: Compiler): void; static defaultOptions: Readonly>; - static loaders: Record; + static loaders: Record; } // @public diff --git a/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts b/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts index add85e6212..1057f79b6d 100644 --- a/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts +++ b/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts @@ -124,9 +124,10 @@ class ReactWebpackPlugin { * * @public */ - static loaders: Record = { + static loaders: Record = { BACKGROUND: require.resolve('../lib/loaders/background.js'), MAIN_THREAD: require.resolve('../lib/loaders/main-thread.js'), + TESTING: require.resolve('../lib/loaders/testing.js'), }; constructor( diff --git a/packages/webpack/react-webpack-plugin/src/loaders/options.ts b/packages/webpack/react-webpack-plugin/src/loaders/options.ts index 1aede58f07..248015a0a8 100644 --- a/packages/webpack/react-webpack-plugin/src/loaders/options.ts +++ b/packages/webpack/react-webpack-plugin/src/loaders/options.ts @@ -14,12 +14,12 @@ import type { } from '@lynx-js/react/transform'; const PLUGIN_NAME = 'react:webpack'; -const JSX_IMPORT_SOURCE = { +export const JSX_IMPORT_SOURCE = { MAIN_THREAD: '@lynx-js/react/lepus', BACKGROUND: '@lynx-js/react', }; const PUBLIC_RUNTIME_PKG = '@lynx-js/react'; -const RUNTIME_PKG = '@lynx-js/react/internal'; +export const RUNTIME_PKG = '@lynx-js/react/internal'; const OLD_RUNTIME_PKG = '@lynx-js/react-runtime'; const COMPONENT_PKG = '@lynx-js/react-components'; diff --git a/packages/webpack/react-webpack-plugin/src/loaders/testing.ts b/packages/webpack/react-webpack-plugin/src/loaders/testing.ts new file mode 100644 index 0000000000..8028a56c82 --- /dev/null +++ b/packages/webpack/react-webpack-plugin/src/loaders/testing.ts @@ -0,0 +1,126 @@ +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { createRequire } from 'node:module'; +import path from 'node:path'; + +import type { LoaderContext } from '@rspack/core'; + +import { JSX_IMPORT_SOURCE, RUNTIME_PKG } from './options.js'; +import type { ReactLoaderOptions } from './options.js'; + +function normalizeSlashes(file: string) { + return file.replaceAll(path.win32.sep, '/'); +} + +function testingLoader( + this: LoaderContext, + content: string, +): void { + const require = createRequire(import.meta.url); + const { + compat = false, + defineDCE = { define: {} }, + engineVersion = '', + shake = false, + transformPath = '@lynx-js/react/transform', + } = this.getOptions(); + const { transformReactLynxSync } = require( + transformPath, + ) as typeof import('@lynx-js/react/transform'); + const filename = normalizeSlashes( + path.relative( + this.rootContext, + this.resourcePath, + ), + ); + const result = transformReactLynxSync( + content, + { + mode: 'test', + pluginName: '', + filename: this.resourcePath, + sourcemap: true, + snapshot: { + preserveJsx: false, + runtimePkg: RUNTIME_PKG, + jsxImportSource: JSX_IMPORT_SOURCE.BACKGROUND, + filename, + target: 'MIXED', + }, + // snapshot: true, + directiveDCE: false, + defineDCE, + shake, + compat, + engineVersion, + worklet: { + filename, + runtimePkg: RUNTIME_PKG, + target: 'MIXED', + }, + refresh: false, + cssScope: false, + }, + ); + + if (result.errors.length > 0) { + for (const error of result.errors) { + if (this.experiments?.emitDiagnostic) { + // Rspack with `emitDiagnostic` API + try { + this.experiments.emitDiagnostic({ + message: error.text!, + sourceCode: content, + location: { + line: error.location?.line ?? 1, + column: error.location?.column ?? 0, + length: error.location?.length ?? 0, + text: error.text ?? '', + }, + severity: 'error', + }); + } catch { + // Rspack may throw on invalid line & column when containing UTF-8. + // We catch it up here. + this.emitError(new Error(error.text)); + } + } else { + // Webpack or legacy Rspack + this.emitError(new Error(error.text)); + } + } + this.callback(new Error('react-transform failed')); + + return; + } + + for (const warning of result.warnings) { + if (this.experiments?.emitDiagnostic) { + // Rspack with `emitDiagnostic` API + try { + this.experiments.emitDiagnostic({ + message: warning.text!, + sourceCode: content, + location: { + line: warning.location?.line ?? 1, + column: warning.location?.column ?? 0, + length: warning.location?.length ?? 0, + text: warning.text ?? '', + }, + severity: 'warning', + }); + } catch { + // Rspack may throw on invalid line & column when containing UTF-8. + // We catch it up here. + this.emitWarning(new Error(warning.text)); + } + } else { + // Webpack or legacy Rspack + this.emitWarning(new Error(warning.text)); + } + } + this.callback(null, result.code, result.map); +} + +export default testingLoader; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 687478f770..38b6b6d39b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,10 @@ catalogs: '@rspack/test-tools': specifier: 1.5.6 version: 1.5.6 + rstest: + '@rstest/core': + specifier: 0.8.1 + version: 0.8.1 overrides: '@rspack/core': 1.7.9 @@ -74,6 +78,9 @@ importers: '@rspack/core': specifier: 1.7.9 version: 1.7.9(@swc/helpers@0.5.21) + '@rstest/core': + specifier: catalog:rstest + version: 0.8.1(jsdom@27.4.0) '@svitejs/changesets-changelog-github-compact': specifier: ^1.2.0 version: 1.2.0 @@ -199,6 +206,34 @@ importers: specifier: ^18.3.28 version: 18.3.28 + examples/gesture: + dependencies: + '@lynx-js/gesture-runtime': + specifier: workspace:* + version: link:../../packages/lynx/gesture-runtime + '@lynx-js/react': + specifier: workspace:* + version: link:../../packages/react + devDependencies: + '@lynx-js/preact-devtools': + specifier: ^5.0.1 + version: 5.0.1 + '@lynx-js/qrcode-rsbuild-plugin': + specifier: workspace:* + version: link:../../packages/rspeedy/plugin-qrcode + '@lynx-js/react-rsbuild-plugin': + specifier: workspace:* + version: link:../../packages/rspeedy/plugin-react + '@lynx-js/rspeedy': + specifier: workspace:* + version: link:../../packages/rspeedy/core + '@lynx-js/types': + specifier: 3.7.0 + version: 3.7.0 + '@types/react': + specifier: ^18.3.28 + version: 18.3.28 + examples/motion: dependencies: '@lynx-js/motion': @@ -616,9 +651,21 @@ importers: '@lynx-js/react': specifier: workspace:* version: link:.. + '@lynx-js/react-rsbuild-plugin': + specifier: workspace:* + version: link:../../rspeedy/plugin-react + '@lynx-js/rspeedy': + specifier: workspace:* + version: link:../../rspeedy/core '@lynx-js/testing-environment': specifier: workspace:* version: link:../../testing-library/testing-environment + '@rsbuild/core': + specifier: catalog:rsbuild + version: 1.7.5 + '@rstest/adapter-rsbuild': + specifier: ^0.2.3 + version: 0.2.3(@rsbuild/core@1.7.5)(@rstest/core@0.8.1(jsdom@27.4.0)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -851,6 +898,9 @@ importers: '@rsbuild/plugin-type-check': specifier: 1.3.4 version: 1.3.4(@rsbuild/core@2.0.0-beta.3(core-js@3.48.0))(@rspack/core@1.7.9(@swc/helpers@0.5.21))(tslib@2.8.1)(typescript@5.9.3) + '@rstest/core': + specifier: catalog:rstest + version: 0.8.1(jsdom@27.4.0) packages/rspeedy/lynx-bundle-rslib-config: dependencies: @@ -992,6 +1042,9 @@ importers: background-only: specifier: workspace:^ version: link:../../background-only + tiny-invariant: + specifier: ^1.3.3 + version: 1.3.3 devDependencies: '@lynx-js/config-rsbuild-plugin': specifier: workspace:* @@ -1161,6 +1214,16 @@ importers: specifier: ^6.9.1 version: 6.9.1 + packages/testing-library/examples/library: + dependencies: + '@lynx-js/react': + specifier: workspace:* + version: link:../../../react + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + packages/testing-library/examples/react-compiler: dependencies: '@lynx-js/react': @@ -1185,6 +1248,9 @@ importers: '@babel/preset-typescript': specifier: ^7.28.5 version: 7.28.5(@babel/core@7.29.0) + '@lynx-js/qrcode-rsbuild-plugin': + specifier: workspace:* + version: link:../../../rspeedy/plugin-qrcode '@lynx-js/react-rsbuild-plugin': specifier: workspace:* version: link:../../../rspeedy/plugin-react @@ -1194,6 +1260,9 @@ importers: '@lynx-js/types': specifier: 3.7.0 version: 3.7.0 + '@rsbuild/plugin-babel': + specifier: 1.1.0 + version: 1.1.0(@rsbuild/core@1.7.5) '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -3739,6 +3808,11 @@ packages: cpu: [x64] os: [win32] + '@rsbuild/core@1.7.2': + resolution: {integrity: sha512-VAFO6cM+cyg2ntxNW6g3tB2Jc5J5mpLjLluvm7VtW2uceNzyUlVv41o66Yp1t1ikxd3ljtqegViXem62JqzveA==} + engines: {node: '>=18.12.0'} + hasBin: true + '@rsbuild/core@1.7.5': resolution: {integrity: sha512-i37urpoV4y9NSsGiUOuLdoI42KJ5h4gAZ8EG8Ilmsond3bxoAoOCu7YvC+1pJ7p+r16suVPW8cki891ZKHOoXQ==} engines: {node: '>=18.12.0'} @@ -3981,6 +4055,25 @@ packages: '@rstack-dev/doc-ui@1.12.3': resolution: {integrity: sha512-5W70pjRxxwyNT3R4kTYDE8cPaMjsJKXMeZQn7+Q54+RCJ1ahN4pADnpaY7WvSEBWkjXdI4IR4GGvBs7nSU/8MA==} + '@rstest/adapter-rsbuild@0.2.3': + resolution: {integrity: sha512-ZKQkY3wI+PLyPJR41xFrAQ0AmenUivo5l1/g97p00+nk1peGMr9gjUxg5gqt3M7qLQZ0RiJZX/KPkPeOltd4lQ==} + peerDependencies: + '@rsbuild/core': '*' + '@rstest/core': '>=0.7.7' + + '@rstest/core@0.8.1': + resolution: {integrity: sha512-7d/2fm2V91pVx/rRtZ2gl6Zh4hVMivtDl4RgHFhBOrxi//UwhKISeF5gS/CSwpCgfOf10TzJRXqdI17ueUBNMQ==} + engines: {node: '>=18.12.0'} + hasBin: true + peerDependencies: + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + happy-dom: + optional: true + jsdom: + optional: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -4050,8 +4143,8 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@sinclair/typebox@0.34.37': - resolution: {integrity: sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==} + '@sinclair/typebox@0.34.38': + resolution: {integrity: sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==} '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} @@ -4312,8 +4405,8 @@ packages: '@types/bonjour@3.5.13': resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} - '@types/chai@5.2.2': - resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} '@types/connect-history-api-fallback@1.5.4': resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==} @@ -5225,9 +5318,9 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@5.2.0: - resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} - engines: {node: '>=12'} + chai@5.2.1: + resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} + engines: {node: '>=18'} chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} @@ -11210,7 +11303,7 @@ snapshots: '@jest/schemas@30.0.5': dependencies: - '@sinclair/typebox': 0.34.37 + '@sinclair/typebox': 0.34.38 '@jest/transform@29.7.0': dependencies: @@ -11979,6 +12072,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.34.9': optional: true + '@rsbuild/core@1.7.2': + dependencies: + '@rspack/core': 1.7.9(@swc/helpers@0.5.21) + '@rspack/lite-tapable': 1.1.0 + '@swc/helpers': 0.5.21 + core-js: 3.47.0 + jiti: 2.6.1 + '@rsbuild/core@1.7.5': dependencies: '@rspack/core': 1.7.9(@swc/helpers@0.5.21) @@ -12440,6 +12541,19 @@ snapshots: - react - react-dom + '@rstest/adapter-rsbuild@0.2.3(@rsbuild/core@1.7.5)(@rstest/core@0.8.1(jsdom@27.4.0))': + dependencies: + '@rsbuild/core': 1.7.5 + '@rstest/core': 0.8.1(jsdom@27.4.0) + + '@rstest/core@0.8.1(jsdom@27.4.0)': + dependencies: + '@rsbuild/core': 1.7.2 + '@types/chai': 5.2.3 + tinypool: 1.1.1 + optionalDependencies: + jsdom: 27.4.0 + '@rtsao/scc@1.1.0': {} '@rushstack/node-core-library@5.22.0(@types/node@24.10.13)': @@ -12534,7 +12648,7 @@ snapshots: '@sinclair/typebox@0.27.8': {} - '@sinclair/typebox@0.34.37': {} + '@sinclair/typebox@0.34.38': {} '@sindresorhus/merge-streams@4.0.0': {} @@ -12756,9 +12870,10 @@ snapshots: dependencies: '@types/node': 24.10.13 - '@types/chai@5.2.2': + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 '@types/connect-history-api-fallback@1.5.4': dependencies: @@ -13212,10 +13327,10 @@ snapshots: '@vitest/expect@3.2.4': dependencies: - '@types/chai': 5.2.2 + '@types/chai': 5.2.3 '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 - chai: 5.2.0 + chai: 5.2.1 tinyrainbow: 2.0.0 '@vitest/mocker@3.2.4(vite@5.4.2(@types/node@24.10.13)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6))': @@ -13804,7 +13919,7 @@ snapshots: ccount@2.0.1: {} - chai@5.2.0: + chai@5.2.1: dependencies: assertion-error: 2.0.1 check-error: 2.1.1 @@ -19207,7 +19322,7 @@ snapshots: vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(@vitest/ui@3.2.4)(jsdom@27.4.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6): dependencies: - '@types/chai': 5.2.2 + '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 '@vitest/mocker': 3.2.4(vite@5.4.2(@types/node@24.10.13)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.31.6)) '@vitest/pretty-format': 3.2.4 @@ -19215,7 +19330,7 @@ snapshots: '@vitest/snapshot': 3.2.4 '@vitest/spy': 3.2.4 '@vitest/utils': 3.2.4 - chai: 5.2.0 + chai: 5.2.1 debug: 4.4.3 expect-type: 1.2.1 magic-string: 0.30.21 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f28c7ece55..03b9dd5ef4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -57,6 +57,10 @@ catalogs: "@rspack/core": "1.7.9" "@rspack/test-tools": "1.5.6" + # Rstest monorepo packages + rstest: + "@rstest/core": "0.8.1" + overrides: "@rspack/core": "$@rspack/core" "@rsbuild/core>@rspack/core": "$@rspack/core" diff --git a/website/docs/en/guide/assets.md b/website/docs/en/guide/assets.md index 1ad2b2840a..aa637c00e3 100644 --- a/website/docs/en/guide/assets.md +++ b/website/docs/en/guide/assets.md @@ -181,4 +181,4 @@ import myFile from './static/myFile.pdf'; console.log(myFile); // "/static/myFile.6c12aba3.pdf" ``` -For more information about asset modules, please refer to [Rspack - Asset modules](https://rspack.dev/guide/features/asset-module). +For more information about asset modules, please refer to [Rspack - Asset modules](https://rspack.rs/guide/features/asset-module). diff --git a/website/docs/en/guide/build-profiling.mdx b/website/docs/en/guide/build-profiling.mdx index 5cb8e7b2ef..f3790fd3bc 100644 --- a/website/docs/en/guide/build-profiling.mdx +++ b/website/docs/en/guide/build-profiling.mdx @@ -23,7 +23,7 @@ Rspeedy supports the use of the `RSPACK_PROFILE` environment variable for Rspack This command will generate a `.rspack-profile-${timestamp}-${pid}` folder in the current directory, containing a `trace.json` file, which is generated by Rspack based on tracing and records the time spent on each phase at a granular level, and can be viewed using [ui.perfetto.dev](https://ui.perfetto.dev/). -> For more information about Rspack build performance analysis usage, please refer to [Rspack - Tracing](https://rspack.dev/contribute/development/tracing). +> For more information about Rspack build performance analysis usage, please refer to [Rspack - Tracing](https://rspack.rs/contribute/development/tracing). ## Node.js Profiling diff --git a/website/docs/en/guide/code-splitting.md b/website/docs/en/guide/code-splitting.md index e60de493d0..b89a628a7f 100644 --- a/website/docs/en/guide/code-splitting.md +++ b/website/docs/en/guide/code-splitting.md @@ -6,7 +6,7 @@ Code Splitting is an experimental feature in Rspeedy. > Rspack supports code splitting, which allows splitting the code into other chunks. You have the full control about size and number of generated assets, which allow you to gain performance improvements in loading time. > -> [Rspack - Code Splitting](https://rspack.dev/guide/optimization/code-splitting) +> [Rspack - Code Splitting](https://rspack.rs/guide/optimization/code-splitting) ## Lazy-loading components diff --git a/website/docs/en/guide/css.mdx b/website/docs/en/guide/css.mdx index 32bd076bd2..b5d8276778 100644 --- a/website/docs/en/guide/css.mdx +++ b/website/docs/en/guide/css.mdx @@ -265,7 +265,7 @@ export default defineConfig({ }) ``` -More options can be used in `pluginSass`, please refer to [Sass Plugin](https://rsbuild.dev/plugins/list/plugin-sass) for usage. +More options can be used in `pluginSass`, please refer to [Sass Plugin](https://rsbuild.rs/plugins/list/plugin-sass) for usage. ### Using `less` @@ -307,7 +307,7 @@ export default defineConfig({ }) ``` -More options can be used in `pluginLess`, please refer to [Less Plugin](https://rsbuild.dev/plugins/list/plugin-less) for usage. +More options can be used in `pluginLess`, please refer to [Less Plugin](https://rsbuild.rs/plugins/list/plugin-less) for usage. ### Using `stylus` @@ -334,7 +334,7 @@ export default defineConfig({ }) ``` -More options can be used in `pluginStylus`, please refer to [Stylus Plugin](https://rsbuild.dev/plugins/list/plugin-stylus) for usage. +More options can be used in `pluginStylus`, please refer to [Stylus Plugin](https://rsbuild.rs/plugins/list/plugin-stylus) for usage. ## Using Lynx Scoped CSS diff --git a/website/docs/en/guide/i18n.mdx b/website/docs/en/guide/i18n.mdx index ec626c1ec4..bb6c14189f 100644 --- a/website/docs/en/guide/i18n.mdx +++ b/website/docs/en/guide/i18n.mdx @@ -119,7 +119,7 @@ export function App() { In a real world project, there are usually multiple resource files for different languages. Instead of static import them one-by-one, -you may use the [`import.meta.webpackContext`](https://rspack.dev/api/runtime-api/module-variables#importmetawebpackcontext) API of Rspack to statically import all the JSON files. +you may use the [`import.meta.webpackContext`](https://rspack.rs/api/runtime-api/module-variables#importmetawebpackcontext) API of Rspack to statically import all the JSON files. diff --git a/website/docs/en/guide/plugin.md b/website/docs/en/guide/plugin.md index 637912e135..685603741c 100644 --- a/website/docs/en/guide/plugin.md +++ b/website/docs/en/guide/plugin.md @@ -28,8 +28,8 @@ You can find the source code of all official plugins in [lynx-stack](https://git The following Rsbuild plugins can be used in Rspeedy. -- [Sass Plugin](https://rsbuild.dev/plugins/list/plugin-sass): Use Sass as the CSS preprocessor. -- [Less Plugin](https://rsbuild.dev/plugins/list/plugin-less): Use Less as the CSS preprocessor. +- [Sass Plugin](https://rsbuild.rs/plugins/list/plugin-sass): Use Sass as the CSS preprocessor. +- [Less Plugin](https://rsbuild.rs/plugins/list/plugin-less): Use Less as the CSS preprocessor. - [ESLint Plugin](https://github.com/rspack-contrib/rsbuild-plugin-eslint): Run ESLint checks during the compilation. - [Type Check Plugin](https://github.com/rspack-contrib/rsbuild-plugin-type-check): Run TypeScript type checker on a separate process. - [Image Compress Plugin](https://github.com/rspack-contrib/rsbuild-plugin-image-compress): Compress the image assets. @@ -54,14 +54,14 @@ If none of the existing ecosystem plugins meet your requirements, you might cons ### Rsbuild Plugin API -See [Rsbuild - Plugin Hooks](https://rsbuild.dev/plugins/dev/hooks) for more details. +See [Rsbuild - Plugin Hooks](https://rsbuild.rs/plugins/dev/hooks) for more details. ### Rspack Plugin API -See [Rspack - Compiler Hooks](https://rspack.dev/api/plugin-api/compiler-hooks) and [Rspack - Compilation Hooks](https://rspack.dev/api/plugin-api/compilation-hooks) for more details. +See [Rspack - Compiler Hooks](https://rspack.rs/api/plugin-api/compiler-hooks) and [Rspack - Compilation Hooks](https://rspack.rs/api/plugin-api/compilation-hooks) for more details. [`tools.rspack.plugins`]: /api/rspeedy.tools.rspack#example-4 -[BannerPlugin]: https://rspack.dev/plugins/webpack/banner-plugin -[DefinePlugin]: https://rspack.dev/plugins/webpack/define-plugin -[EnvironmentPlugin]: https://rspack.dev/plugins/webpack/environment-plugin -[ProvidePlugin]: https://rspack.dev/plugins/webpack/provide-plugin +[BannerPlugin]: https://rspack.rs/plugins/webpack/banner-plugin +[DefinePlugin]: https://rspack.rs/plugins/webpack/define-plugin +[EnvironmentPlugin]: https://rspack.rs/plugins/webpack/environment-plugin +[ProvidePlugin]: https://rspack.rs/plugins/webpack/provide-plugin diff --git a/website/docs/en/guide/resolve.md b/website/docs/en/guide/resolve.md index 24617135d3..21f91a2171 100644 --- a/website/docs/en/guide/resolve.md +++ b/website/docs/en/guide/resolve.md @@ -38,7 +38,7 @@ You can refer to the [TypeScript - paths](https://typescriptlang.org/tsconfig#pa ### Use `resolve.alias` Configuration -Rsbuild provides the [resolve.alias](/api/rspeedy.resolve.alias) configuration option, which corresponds to the webpack/Rspack native [resolve.alias](https://rspack.dev/config/resolve#resolvealias) configuration. You can configure this option using an object or a function. +Rsbuild provides the [resolve.alias](/api/rspeedy.resolve.alias) configuration option, which corresponds to the webpack/Rspack native [resolve.alias](https://rspack.rs/config/resolve#resolvealias) configuration. You can configure this option using an object or a function. #### Use Cases diff --git a/website/docs/zh/guide/assets.md b/website/docs/zh/guide/assets.md index fbf3773074..72bde1e8b7 100644 --- a/website/docs/zh/guide/assets.md +++ b/website/docs/zh/guide/assets.md @@ -168,7 +168,7 @@ import myFile from './static/myFile.pdf'; console.log(myFile); // "/static/myFile.6c12aba3.pdf" ``` -有关资源模块的更多信息,请参考 [Rspack - 资源模块](https://rspack.dev/guide/features/asset-module)。 +有关资源模块的更多信息,请参考 [Rspack - 资源模块](https://rspack.rs/guide/features/asset-module)。 [`dev.assetPrefix`]: ../../api/rspeedy.dev.assetprefix [`output.assetPrefix`]: ../../api/rspeedy.output.assetprefix diff --git a/website/docs/zh/guide/build-profiling.mdx b/website/docs/zh/guide/build-profiling.mdx index 20236fe67e..5a39b9ac39 100644 --- a/website/docs/zh/guide/build-profiling.mdx +++ b/website/docs/zh/guide/build-profiling.mdx @@ -22,7 +22,7 @@ RSPACK_PROFILE=OVERVIEW rspeedy build 当 build 命令执行完成,或是 dev server 被关闭时,Rspeedy 会在当前目录下生成一个 `.rspack-profile-${timestamp}-${pid}` 文件夹,其中包含 `trace.json` 文件,该文件由 Rspack 基于 tracing 细粒度地记录了各个阶段的耗时,可以使用 [ui.perfetto.dev](https://ui.perfetto.dev/) 进行查看。 -> 有关 Rspack 构建性能分析使用的更多信息,请参阅 [Rspack - Tracing](https://rspack.dev/contribute/development/tracing)。 +> 有关 Rspack 构建性能分析使用的更多信息,请参阅 [Rspack - Tracing](https://rspack.rs/contribute/development/tracing)。 ## Node.js profiling diff --git a/website/docs/zh/guide/code-splitting.md b/website/docs/zh/guide/code-splitting.md index b220bc9a41..d749c1d10d 100644 --- a/website/docs/zh/guide/code-splitting.md +++ b/website/docs/zh/guide/code-splitting.md @@ -2,7 +2,7 @@ > Rspack 支持代码分割特性,允许让你对代码进行分割,控制生成的资源体积和资源数量来获取资源加载性能的提升。 > -> [Rspack - 代码分割](https://rspack.dev/zh/guide/optimization/code-splitting) +> [Rspack - 代码分割](https://rspack.rs/zh/guide/optimization/code-splitting) ## 懒加载组件 diff --git a/website/docs/zh/guide/css.mdx b/website/docs/zh/guide/css.mdx index a0c9df74f2..9c91b3250e 100644 --- a/website/docs/zh/guide/css.mdx +++ b/website/docs/zh/guide/css.mdx @@ -136,7 +136,7 @@ declare module '*.module.css' { 它对于每个 CSS Modules 中含有的类名不够精确。 -使用 [Typed CSS Modules Plugin](https://rsbuild.dev/plugins/list/plugin-typed-css-modules) 将为所有 CSS Modules 生成具有精确类型声明的类型声明文件。 +使用 [Typed CSS Modules Plugin](https://rsbuild.rs/plugins/list/plugin-typed-css-modules) 将为所有 CSS Modules 生成具有精确类型声明的类型声明文件。 1. 安装 `@rsbuild/plugin-typed-css-modules` @@ -260,7 +260,7 @@ export default defineConfig({ }); ``` -在 `pluginSass` 中可以使用更多选项,请参阅 [Sass 插件](https://rsbuild.dev/plugins/list/plugin-sass)以了解用法。 +在 `pluginSass` 中可以使用更多选项,请参阅 [Sass 插件](https://rsbuild.rs/plugins/list/plugin-sass)以了解用法。 ### 使用 `less` @@ -302,7 +302,7 @@ export default defineConfig({ }); ``` -在 `pluginLess` 中可以使用更多选项,请参阅 [Less 插件](https://rsbuild.dev/plugins/list/plugin-less)以了解用法。 +在 `pluginLess` 中可以使用更多选项,请参阅 [Less 插件](https://rsbuild.rs/plugins/list/plugin-less)以了解用法。 ## 使用 PostCSS \{#using-postcss} diff --git a/website/docs/zh/guide/i18n.mdx b/website/docs/zh/guide/i18n.mdx index fa64c5d8ea..ae7e76d3a2 100644 --- a/website/docs/zh/guide/i18n.mdx +++ b/website/docs/zh/guide/i18n.mdx @@ -114,7 +114,7 @@ export function App() { 在真实的项目中,通常有多个不同语言的文案资源。 -你可以使用 [`import.meta.webpackContext`](https://rspack.dev/api/runtime-api/module-variables#importmetawebpackcontext) API 来一次性将他们全部引入: +你可以使用 [`import.meta.webpackContext`](https://rspack.rs/api/runtime-api/module-variables#importmetawebpackcontext) API 来一次性将他们全部引入: diff --git a/website/docs/zh/guide/plugin.md b/website/docs/zh/guide/plugin.md index 7e36576ea5..21c7325b8a 100644 --- a/website/docs/zh/guide/plugin.md +++ b/website/docs/zh/guide/plugin.md @@ -17,8 +17,8 @@ Rsbuild 提供了一套强大的插件系统,允许用户进行功能扩展。 以下 Rsbuild 插件可直接在 Rspeedy 中使用: -- [Sass 插件](https://rsbuild.dev/plugins/list/plugin-sass): 使用 Sass 作为 CSS 预处理器 -- [Less 插件](https://rsbuild.dev/plugins/list/plugin-less): 使用 Less 作为 CSS 预处理器 +- [Sass 插件](https://rsbuild.rs/plugins/list/plugin-sass): 使用 Sass 作为 CSS 预处理器 +- [Less 插件](https://rsbuild.rs/plugins/list/plugin-less): 使用 Less 作为 CSS 预处理器 - [ESLint 插件](https://github.com/rspack-contrib/rsbuild-plugin-eslint): 在编译过程中执行 ESLint 检查 - [TypeScript 类型检查插件](https://github.com/rspack-contrib/rsbuild-plugin-type-check): 在独立进程中进行 TypeScript 类型检查 - [图片压缩插件](https://github.com/rspack-contrib/rsbuild-plugin-image-compress): 压缩图片资源 @@ -43,14 +43,14 @@ Rspack/Webpack 插件需要配置在 [`tools.rspack.plugins`] 中 ### Rsbuild 插件 API -详见 [Rsbuild - 插件钩子](https://rsbuild.dev/plugins/dev/hooks) +详见 [Rsbuild - 插件钩子](https://rsbuild.rs/plugins/dev/hooks) ### Rspack 插件 API -详见 [Rspack - Compiler 钩子](https://rspack.dev/api/plugin-api/compiler-hooks)和 [Rspack - Compilation 钩子](https://rspack.dev/api/plugin-api/compilation-hooks) +详见 [Rspack - Compiler 钩子](https://rspack.rs/api/plugin-api/compiler-hooks)和 [Rspack - Compilation 钩子](https://rspack.rs/api/plugin-api/compilation-hooks) [`tools.rspack.plugins`]: ../../api/rspeedy.tools.rspack#example-4 -[Banner 插件]: https://rspack.dev/plugins/webpack/banner-plugin -[Define 插件]: https://rspack.dev/plugins/webpack/define-plugin -[Environment 插件]: https://rspack.dev/plugins/webpack/environment-plugin -[Provide 插件]: https://rspack.dev/plugins/webpack/provide-plugin +[Banner 插件]: https://rspack.rs/plugins/webpack/banner-plugin +[Define 插件]: https://rspack.rs/plugins/webpack/define-plugin +[Environment 插件]: https://rspack.rs/plugins/webpack/environment-plugin +[Provide 插件]: https://rspack.rs/plugins/webpack/provide-plugin diff --git a/website/docs/zh/guide/resolve.md b/website/docs/zh/guide/resolve.md index 49866f1f1a..2d4565758a 100644 --- a/website/docs/zh/guide/resolve.md +++ b/website/docs/zh/guide/resolve.md @@ -39,7 +39,7 @@ ### 使用 `resolve.alias` 配置 -Rsbuild 提供 [resolve.alias](../../api/rspeedy.resolve.alias) 配置项,对应 webpack/Rspack 原生的 [resolve.alias](https://rspack.dev/config/resolve#resolvealias) 配置。可通过对象或函数形式进行配置。 +Rsbuild 提供 [resolve.alias](../../api/rspeedy.resolve.alias) 配置项,对应 webpack/Rspack 原生的 [resolve.alias](https://rspack.rs/config/resolve#resolvealias) 配置。可通过对象或函数形式进行配置。 #### 使用场景 diff --git a/website/src/styles/external-links.scss b/website/src/styles/external-links.scss index e13563dc17..67626e19d3 100644 --- a/website/src/styles/external-links.scss +++ b/website/src/styles/external-links.scss @@ -96,7 +96,7 @@ } // Rspack - a[target="_blank"][href*="rspack.dev"] { + a[target="_blank"][href*="rspack.rs"] { @include link-prefix("🦀"); &::after { content: "↗"; @@ -104,7 +104,7 @@ } @include external-link-hover; } - a[target="_blank"][href*="rsbuild.dev"] { + a[target="_blank"][href*="rsbuild.rs"] { @include link-prefix("🦀"); &::after { content: "↗";