diff --git a/.changeset/funny-weeks-stand.md b/.changeset/funny-weeks-stand.md new file mode 100644 index 0000000000..5653054f79 --- /dev/null +++ b/.changeset/funny-weeks-stand.md @@ -0,0 +1,12 @@ +--- +'@lynx-js/react': minor +--- + +feat: export `GlobalPropsProvider`, `GlobalPropsConsumer`, `useGlobalProps` and `useGlobalPropsChanged` for `__globalProps` + +- `GlobalPropsProvider`: A Provider component that accepts `children`. It is used to provide the `lynx.__globalProps` context. +- `GlobalPropsConsumer`: A Consumer component that accepts a function as a child. It is used to consume the `lynx.__globalProps` context. +- `useGlobalProps`: A hook that returns the `lynx.__globalProps` object. It triggers a re-render when `lynx.__globalProps` changes. +- `useGlobalPropsChanged`: A hook that accepts a callback function. The callback is invoked when `lynx.__globalProps` changes. + +Note: When `globalPropsMode` is not set to `'event'` (default is `'reactive'`), these APIs will be ineffective (pass-through) and will log a warning in development mode, as updates are triggered automatically by full re-render. diff --git a/.changeset/thirty-cycles-find.md b/.changeset/thirty-cycles-find.md new file mode 100644 index 0000000000..0e7d920c85 --- /dev/null +++ b/.changeset/thirty-cycles-find.md @@ -0,0 +1,10 @@ +--- +'@lynx-js/react-rsbuild-plugin': minor +"@lynx-js/react-webpack-plugin": minor +'@lynx-js/react': minor +--- + +feat: add `globalPropsMode` option to `PluginReactLynxOptions` + +- When configured to `"event"`, `updateGlobalProps` will only trigger a global event and skip the `runWithForce` flow. +- Defaults to `"reactive"`, which means `updateGlobalProps` will trigger re-render automatically. diff --git a/packages/react/etc/react.api.md b/packages/react/etc/react.api.md index d8aa00c05f..4a230fffc0 100644 --- a/packages/react/etc/react.api.md +++ b/packages/react/etc/react.api.md @@ -66,6 +66,22 @@ export { forwardRef } export { Fragment } +// @public +export interface GlobalProps { +} + +// Warning: (tsdoc-undefined-tag) The TSDoc tag "@group" is not defined in this configuration +// +// @public +export const GlobalPropsConsumer: Consumer; + +// Warning: (tsdoc-undefined-tag) The TSDoc tag "@group" is not defined in this configuration +// +// @public +export const GlobalPropsProvider: FC<{ + children?: ReactNode | undefined; +}>; + // @public export interface InitData { } @@ -136,6 +152,12 @@ export const useEffect: (effect: EffectCallback, deps?: DependencyList) => void; export { useErrorBoundary } +// @public +export const useGlobalProps: () => GlobalProps; + +// @public +export const useGlobalPropsChanged: (callback: (data: GlobalProps) => void) => void; + export { useImperativeHandle } // @public diff --git a/packages/react/runtime/__test__/lifecycle/updateGlobalProps.test.jsx b/packages/react/runtime/__test__/lifecycle/updateGlobalProps.test.jsx index e5c5eda9c3..8f7561b6bb 100644 --- a/packages/react/runtime/__test__/lifecycle/updateGlobalProps.test.jsx +++ b/packages/react/runtime/__test__/lifecycle/updateGlobalProps.test.jsx @@ -2,8 +2,7 @@ // 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 { beforeEach } from 'vitest'; -import { __root } from '../../src/root'; +import { beforeEach, afterEach, vi } from 'vitest'; import { globalEnvManager } from '../utils/envManager'; import { describe } from 'vitest'; import { it } from 'vitest'; @@ -12,6 +11,8 @@ import { render } from 'preact'; import { waitSchedule } from '../utils/nativeMethod'; import { beforeAll } from 'vitest'; import { replaceCommitHook } from '../../src/lifecycle/patch/commit'; +import { elementTree } from '../utils/nativeMethod'; +import { __root } from '../../src/root'; beforeAll(() => { replaceCommitHook(); @@ -21,6 +22,13 @@ beforeEach(() => { globalEnvManager.resetEnv(); }); +afterEach(() => { + elementTree.clear(); + vi.resetModules(); + vi.restoreAllMocks(); + globalThis.__GLOBAL_PROPS_MODE__ = 'reactive'; +}); + describe('updateGlobalProps', () => { it('should update global props', async () => { lynx.__globalProps = { theme: 'dark' }; @@ -103,4 +111,467 @@ describe('updateGlobalProps', () => { `); } }); + + it('should update global props once when use useGlobalProps and get warning', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { useGlobalProps } = await import('../../src/lynx-api'); + const Comp = () => { + const globalProps = useGlobalProps(); + return {globalProps.theme}; + }; + + // main thread render + { + __root.__jsx = ; + renderPage(); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + + // background render + { + globalEnvManager.switchToBackground(); + __root.__jsx = ; + render(, __root); + expect(console.warn).toBeCalledWith(expect.stringContaining('No need to use this API')); + } + + // hydrate + { + // LifecycleConstant.firstScreen + lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + } + + // rLynxChange + { + globalEnvManager.switchToMainThread(); + globalThis.__OnLifecycleEvent.mockClear(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); + await waitSchedule(); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + + // updateGlobalProps + { + globalEnvManager.switchToBackground(); + lynx.getNativeApp().callLepusMethod.mockClear(); + lynxCoreInject.tt.updateGlobalProps({ theme: 'light' }); + await waitSchedule(); + + globalEnvManager.switchToMainThread(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + }); + + it('should update global props once when use GlobalPropsConsumer / GlobalPropsProvider and get warning', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { GlobalPropsProvider, GlobalPropsConsumer, useGlobalPropsChanged } = await import('../../src/lynx-api'); + let count = 0; + let dataTheme, globalPropsTheme; + const Comp = () => { + useGlobalPropsChanged(data => { + count++; + dataTheme = data.theme; + globalPropsTheme = lynx.__globalProps.theme; + }); + return ( + + + {globalProps => { + return {globalProps.theme}; + }} + + + ); + }; + + // main thread render + { + __root.__jsx = ; + renderPage(); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + + // background render + { + globalEnvManager.switchToBackground(); + __root.__jsx = ; + render(, __root); + expect(console.warn).toBeCalledWith(expect.stringContaining('No need to use this API')); + } + + // hydrate + { + // LifecycleConstant.firstScreen + lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + } + + // rLynxChange + { + globalEnvManager.switchToMainThread(); + globalThis.__OnLifecycleEvent.mockClear(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); + await waitSchedule(); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + + // updateGlobalProps + { + globalEnvManager.switchToBackground(); + lynx.getNativeApp().callLepusMethod.mockClear(); + lynxCoreInject.tt.updateGlobalProps({ theme: 'light' }); + expect(count).toBe(1); + expect(dataTheme).toBe('light'); + expect(globalPropsTheme).toBe('light'); + + await waitSchedule(); + + globalEnvManager.switchToMainThread(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + }); + + it('should not trigger re-render when __GLOBAL_PROPS_MODE__ is event', async () => { + globalThis.__GLOBAL_PROPS_MODE__ = 'event'; + + lynx.__globalProps = { theme: 'dark' }; + const Comp = () => { + return {lynx.__globalProps.theme}; + }; + + // main thread render + { + __root.__jsx = ; + renderPage(); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + + // background render + { + globalEnvManager.switchToBackground(); + __root.__jsx = ; + render(, __root); + } + + // hydrate + { + lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + } + + // rLynxChange + { + globalEnvManager.switchToMainThread(); + globalThis.__OnLifecycleEvent.mockClear(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + await waitSchedule(); + } + + // updateGlobalProps + { + globalEnvManager.switchToBackground(); + lynx.getNativeApp().callLepusMethod.mockClear(); + lynxCoreInject.tt.updateGlobalProps({ theme: 'light' }); + await waitSchedule(); + + // No rLynxChange should be called because it skips runWithForce + expect(lynx.getNativeApp().callLepusMethod.mock.calls.length).toBe(0); + + // No ui change + globalEnvManager.switchToMainThread(); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + }); + + it('should trigger re-render when useGlobalProps is called', async () => { + globalThis.__GLOBAL_PROPS_MODE__ = 'event'; + const { useGlobalProps, useGlobalPropsChanged, GlobalPropsProvider, GlobalPropsConsumer } = await import( + '../../src/lynx-api' + ); + + lynx.__globalProps = { theme: 'dark' }; + let count = 0; + let dataTheme, globalPropsTheme; + const Comp = () => { + const globalProps = useGlobalProps(); + useGlobalPropsChanged(data => { + count++; + dataTheme = data.theme; + globalPropsTheme = lynx.__globalProps.theme; + }); + return {globalProps.theme}; + }; + + // main thread render + { + __root.__jsx = ; + renderPage(); + expect(count).toBe(0); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + + // background render + { + globalEnvManager.switchToBackground(); + __root.__jsx = ; + render(, __root); + } + + // hydrate + { + lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + } + + // rLynxChange + { + globalEnvManager.switchToMainThread(); + globalThis.__OnLifecycleEvent.mockClear(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + await waitSchedule(); + } + + // updateGlobalProps + { + globalEnvManager.switchToBackground(); + lynx.getNativeApp().callLepusMethod.mockClear(); + lynxCoreInject.tt.updateGlobalProps({ theme: 'light' }); + await waitSchedule(); + + // rLynxChange should be called + expect(lynx.getNativeApp().callLepusMethod.mock.calls.length).toBe(1); + expect(count).toBe(1); + expect(dataTheme).toBe('light'); + expect(globalPropsTheme).toBe('light'); + // ui change + globalEnvManager.switchToMainThread(); + for (const rLynxChange of lynx.getNativeApp().callLepusMethod.mock.calls) { + globalThis[rLynxChange[0]](rLynxChange[1]); + rLynxChange[2](); + } + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + }); + + it('should trigger update when GlobalPropsProvider and useGlobalProps are used', async () => { + globalThis.__GLOBAL_PROPS_MODE__ = 'event'; + const { useGlobalProps, useGlobalPropsChanged, GlobalPropsProvider, GlobalPropsConsumer } = await import( + '../../src/lynx-api' + ); + + lynx.__globalProps = { theme: 'dark' }; + const Comp = () => { + return ( + + + {globalProps => { + return {globalProps.theme}; + }} + + + ); + }; + + // main thread render + { + __root.__jsx = ; + renderPage(); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + + // background render + { + globalEnvManager.switchToBackground(); + __root.__jsx = ; + render(, __root); + } + + // hydrate + { + lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + } + + // rLynxChange + { + globalEnvManager.switchToMainThread(); + globalThis.__OnLifecycleEvent.mockClear(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + await waitSchedule(); + } + + // updateGlobalProps + { + globalEnvManager.switchToBackground(); + lynx.getNativeApp().callLepusMethod.mockClear(); + lynxCoreInject.tt.updateGlobalProps({ theme: 'light' }); + await waitSchedule(); + + // rLynxChange should be called + expect(lynx.getNativeApp().callLepusMethod.mock.calls.length).toBe(2); + expect(lynx.getNativeApp().callLepusMethod.mock.calls).toMatchInlineSnapshot(` + [ + [ + "rLynxChange", + { + "data": "{"patchList":[{"id":17}]}", + "patchOptions": { + "flowIds": [ + 666, + ], + "reloadVersion": 0, + }, + }, + [Function], + ], + [ + "rLynxChange", + { + "data": "{"patchList":[{"id":18,"snapshotPatch":[3,-3,0,"light"]}]}", + "patchOptions": { + "reloadVersion": 0, + }, + }, + [Function], + ], + ] + `); + // ui change + globalEnvManager.switchToMainThread(); + for (const rLynxChange of lynx.getNativeApp().callLepusMethod.mock.calls) { + globalThis[rLynxChange[0]](rLynxChange[1]); + rLynxChange[2](); + } + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); + } + }); }); diff --git a/packages/react/runtime/__test__/utils/globals.js b/packages/react/runtime/__test__/utils/globals.js index 0ca272d334..ac3b8f6319 100644 --- a/packages/react/runtime/__test__/utils/globals.js +++ b/packages/react/runtime/__test__/utils/globals.js @@ -149,6 +149,7 @@ function injectGlobals() { globalThis.__REF_FIRE_IMMEDIATELY__ = false; globalThis.__ENABLE_SSR__ = true; globalThis.__FIRST_SCREEN_SYNC_TIMING__ = 'immediately'; + globalThis.__GLOBAL_PROPS_MODE__ = 'reactive'; globalThis.globDynamicComponentEntry = '__Card__'; globalThis.lynxCoreInject = {}; globalThis.lynxCoreInject.tt = { diff --git a/packages/react/runtime/lazy/compat.js b/packages/react/runtime/lazy/compat.js index 1a6f1d5537..fc7f065114 100644 --- a/packages/react/runtime/lazy/compat.js +++ b/packages/react/runtime/lazy/compat.js @@ -10,6 +10,8 @@ export const { Fragment, InitDataConsumer, InitDataProvider, + GlobalPropsProvider, + GlobalPropsConsumer, MainThreadRef, PureComponent, Suspense, @@ -33,6 +35,8 @@ export const { useImperativeHandle, useInitData, useInitDataChanged, + useGlobalProps, + useGlobalPropsChanged, useLynxGlobalEventListener, useLayoutEffect, useMainThreadRef, diff --git a/packages/react/runtime/lazy/react.js b/packages/react/runtime/lazy/react.js index e00fb51cc8..d4d5bb5d33 100644 --- a/packages/react/runtime/lazy/react.js +++ b/packages/react/runtime/lazy/react.js @@ -10,6 +10,8 @@ export const { Fragment, InitDataConsumer, InitDataProvider, + GlobalPropsConsumer, + GlobalPropsProvider, MainThreadRef, PureComponent, Suspense, @@ -34,6 +36,8 @@ export const { useInitData, useInitDataChanged, useLynxGlobalEventListener, + useGlobalProps, + useGlobalPropsChanged, useLayoutEffect, useMainThreadRef, useMemo, diff --git a/packages/react/runtime/src/lynx-api.ts b/packages/react/runtime/src/lynx-api.ts index 076a6f2933..e975250087 100644 --- a/packages/react/runtime/src/lynx-api.ts +++ b/packages/react/runtime/src/lynx-api.ts @@ -189,24 +189,138 @@ export const useInitData: () => InitData = /* @__PURE__ */ _InitData.use(); */ export const useInitDataChanged: (callback: (data: InitData) => void) => void = /* @__PURE__ */ _InitData.useChanged(); -// const { -// Provider: GlobalPropsProvider, -// Consumer: GlobalPropsConsumer, -// // InitDataContext, -// use: useGlobalProps, -// useChanged: useGlobalPropsChanged, -// } = /* @__PURE__ */ factory( -// { -// createContext, -// useState, -// useEffect, -// createElement, -// } as any, -// "__globalProps", -// "onGlobalPropsChanged" -// ); +/** + * The interface you can extends so that the `useGlobalProps` returning value can be customized + * + * @public + */ +export interface GlobalProps {} + +const _GlobalProps = typeof __GLOBAL_PROPS_MODE__ !== 'undefined' && __GLOBAL_PROPS_MODE__ === 'event' + ? /* @__PURE__ */ factory( + { + createContext, + useState, + createElement, + useLynxGlobalEventListener, + }, + '__globalProps', + 'onGlobalPropsChanged', + ) + : /* @__PURE__ */ createFallbackGlobalProps(); + +function warnGlobalPropsMode() { + if (typeof __LEPUS__ !== 'undefined' && !__LEPUS__ && typeof __DEV__ !== 'undefined' && __DEV__) { + console.warn( + `No need to use this API when 'globalPropsMode' is not 'event', ` + + `updates will be triggered automatically by full re-render. ` + + `Please set 'globalPropsMode' to 'event' to enable optimized updates.`, + ); + } +} + +function createFallbackGlobalProps() { + return { + Provider: () => { + return ({ children }: { children?: ReactNode | undefined }) => { + warnGlobalPropsMode(); + return children; + }; + }, + Consumer: () => { + return ({ children }: { children: (data: GlobalProps) => ReactNode }) => { + warnGlobalPropsMode(); + return children(lynx.__globalProps); + }; + }, + use: () => { + return (): GlobalProps => { + warnGlobalPropsMode(); + return lynx.__globalProps; + }; + }, + useChanged: () => { + return (callback: (data: GlobalProps) => void): void => { + if (!__LEPUS__) { + useLynxGlobalEventListener('onGlobalPropsChanged', callback); + } + }; + }, + }; +} + +/** + * The {@link https://react.dev/reference/react/createContext#provider | Provider} Component that provide `lynx.__globalProps`, + * you must wrap your JSX inside it + * @group Components + * + * @example + * + * ```ts + * import { root } from "@lynx-js/react" + * + * function App() { + * return ( + * ...}/> + * ) + * } + * + * root.render( + * + * + * + * ); + * + * ``` + * + * @public + */ +// @ts-expect-error make preact and react types work +export const GlobalPropsProvider: FC<{ children?: ReactNode | undefined }> = /* @__PURE__ */ _GlobalProps.Provider(); + +/** + * The {@link https://react.dev/reference/react/createContext#consumer | Consumer} Component that provide `lynx.__globalProps`. + * This should be used with {@link GlobalPropsProvider} + * @group Components + * @public + */ +// @ts-expect-error make preact and react types work +export const GlobalPropsConsumer: Consumer = /* @__PURE__ */ _GlobalProps.Consumer(); -// export { GlobalPropsProvider, GlobalPropsConsumer, useGlobalProps, useGlobalPropsChanged }; +/** + * A React Hooks for you to get `lynx.__globalProps`. + * If `lynx.__globalProps` is changed, a re-render will be triggered automatically. + * + * @example + * + * ```ts + * function App() { + * const globalProps = useGlobalProps(); + * + * globalProps.someProperty // use it + * } + * ``` + * + * @public + */ +export const useGlobalProps: () => GlobalProps = /* @__PURE__ */ _GlobalProps.use(); + +/** + * A React Hooks for you to get notified when `__globalProps` changed. + * + * @example + * ```ts + * function App() { + * useGlobalPropsChanged((data) => { + * lynx.__globalProps.someProperty // can use lynx.__globalProps + * data.someProperty // can use data + * }) + * } + * ``` + * @public + */ +export const useGlobalPropsChanged: (callback: (data: GlobalProps) => void) => void = /* @__PURE__ */ _GlobalProps + .useChanged(); /** * The interface you can extends so that the `defaultDataProcessor` parameter can be customized diff --git a/packages/react/runtime/src/lynx/tt.ts b/packages/react/runtime/src/lynx/tt.ts index 03aec47cb7..61166746ab 100644 --- a/packages/react/runtime/src/lynx/tt.ts +++ b/packages/react/runtime/src/lynx/tt.ts @@ -263,17 +263,22 @@ function delayedPublicComponentEvent(_componentId: string, handlerName: string, } function updateGlobalProps(newData: Record): void { - Object.assign(lynx.__globalProps, newData); - - // Our purpose is to make sure SYNC setState inside `emit`'s listeners - // can be batched with updateFromRoot - // This is already done because updateFromRoot will consume all dirty flags marked by - // the setState, and setState's flush will be a noop. No extra diffs will be needed. - void Promise.resolve().then(() => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - runWithForce(() => render(__root.__jsx, __root as any)); - }); - lynxCoreInject.tt.GlobalEventEmitter.emit('onGlobalPropsChanged', undefined); + if (typeof __GLOBAL_PROPS_MODE__ !== 'undefined' && __GLOBAL_PROPS_MODE__ === 'event') { + // COW when modify `lynx.__globalProps` to make sure Provider & Consumer works + lynx.__globalProps = Object.assign({}, lynx.__globalProps, newData); + } else { + // only when __GLOBAL_PROPS_MODE__ is reactive, we need to batch the update with updateFromRoot + Object.assign(lynx.__globalProps, newData); + // Our purpose is to make sure SYNC setState inside `emit`'s listeners + // can be batched with updateFromRoot + // This is already done because updateFromRoot will consume all dirty flags marked by + // the setState, and setState's flush will be a noop. No extra diffs will be needed. + void Promise.resolve().then(() => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + runWithForce(() => render(__root.__jsx, __root as any)); + }); + } + lynxCoreInject.tt.GlobalEventEmitter.emit('onGlobalPropsChanged', [lynx.__globalProps]); } function updateCardData(newData: Record, options?: Record): void { diff --git a/packages/react/runtime/types/types.d.ts b/packages/react/runtime/types/types.d.ts index 3ebeaa0bdc..c3a8333e31 100644 --- a/packages/react/runtime/types/types.d.ts +++ b/packages/react/runtime/types/types.d.ts @@ -22,6 +22,7 @@ declare global { declare const __ALOG__: boolean | undefined; declare const __ALOG_ELEMENT_API__: boolean | undefined; declare const __ENABLE_SSR__: boolean; + declare const __GLOBAL_PROPS_MODE__: 'reactive' | 'event' | undefined; declare function __CreatePage(componentId: string, cssId: number): FiberElement; declare function __CreateElement( diff --git a/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md b/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md index 8ff1f93e37..14682f1ddf 100644 --- a/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md +++ b/packages/rspeedy/plugin-react/etc/react-rsbuild-plugin.api.md @@ -80,6 +80,7 @@ export interface PluginReactLynxOptions { experimental_isLazyBundle?: boolean; extractStr?: Partial | boolean; firstScreenSyncTiming?: 'immediately' | 'jsReady'; + globalPropsMode?: 'reactive' | 'event'; removeDescendantSelectorScope?: boolean; shake?: Partial | undefined; // @deprecated diff --git a/packages/rspeedy/plugin-react/src/entry.ts b/packages/rspeedy/plugin-react/src/entry.ts index 52151fa067..e6a854ccfe 100644 --- a/packages/rspeedy/plugin-react/src/entry.ts +++ b/packages/rspeedy/plugin-react/src/entry.ts @@ -47,6 +47,7 @@ export function applyEntry( enableNewGesture, enableRemoveCSSScope, firstScreenSyncTiming, + globalPropsMode, enableSSR, removeDescendantSelectorScope, targetSdkVersion, @@ -272,6 +273,7 @@ export function applyEntry( disableCreateSelectorQueryIncompatibleWarning: compat ?.disableCreateSelectorQueryIncompatibleWarning ?? false, firstScreenSyncTiming, + globalPropsMode, enableSSR, mainThreadChunks, extractStr, diff --git a/packages/rspeedy/plugin-react/src/pluginReactLynx.ts b/packages/rspeedy/plugin-react/src/pluginReactLynx.ts index c73b921824..87a042a54e 100644 --- a/packages/rspeedy/plugin-react/src/pluginReactLynx.ts +++ b/packages/rspeedy/plugin-react/src/pluginReactLynx.ts @@ -251,6 +251,19 @@ export interface PluginReactLynxOptions { */ targetSdkVersion?: string + /** + * Configure the update mode of `lynx.__globalProps`. + * + * This flag has two options: + * + * `'reactive'`: `UpdateGlobalProps` will trigger update automatically. + * + * `'event'`: `UpdateGlobalProps` will trigger global event and users need to trigger update in the event handler. + * + * @defaultValue `'reactive'` + */ + globalPropsMode?: 'reactive' | 'event' + /** * Merge same string literals in JS and Lepus to reduce output bundle size. * Set to `false` to disable. @@ -311,6 +324,8 @@ export function pluginReactLynx( engineVersion: '', extractStr: false, + globalPropsMode: 'reactive', + experimental_isLazyBundle: false, } const resolvedOptions = Object.assign(defaultOptions, userOptions, { diff --git a/packages/rspeedy/plugin-react/test/config.test.ts b/packages/rspeedy/plugin-react/test/config.test.ts index b07cab109c..a486f6343e 100644 --- a/packages/rspeedy/plugin-react/test/config.test.ts +++ b/packages/rspeedy/plugin-react/test/config.test.ts @@ -670,6 +670,60 @@ describe('Config', () => { expect(firstScreenSyncTiming).toBe('immediately') }) + test('globalPropsMode defaults to "reactive"', async () => { + const { pluginReactLynx } = await import('../src/pluginReactLynx.js') + const rsbuild = await createRspeedy({ + rspeedyConfig: { + plugins: [ + pluginReactLynx(), + pluginStubRspeedyAPI(), + ], + }, + }) + + const [config] = await rsbuild.initConfigs() + + const ReactWebpackPlugin = config?.plugins?.find(( + p, + ): p is ReactWebpackPlugin => + p?.constructor.name === 'ReactWebpackPlugin' + ) + + expect(ReactWebpackPlugin).toBeDefined() + + // @ts-expect-error private field + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { globalPropsMode } = ReactWebpackPlugin?.options ?? {} + expect(globalPropsMode).toBe('reactive') + }) + + test('globalPropsMode respects configuration', async () => { + const { pluginReactLynx } = await import('../src/pluginReactLynx.js') + const rsbuild = await createRspeedy({ + rspeedyConfig: { + plugins: [ + pluginReactLynx({ globalPropsMode: 'event' }), + pluginStubRspeedyAPI(), + ], + }, + }) + + const [config] = await rsbuild.initConfigs() + + const ReactWebpackPlugin = config?.plugins?.find(( + p, + ): p is ReactWebpackPlugin => + p?.constructor.name === 'ReactWebpackPlugin' + ) + + expect(ReactWebpackPlugin).toBeDefined() + + // @ts-expect-error private field + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { globalPropsMode } = ReactWebpackPlugin?.options ?? {} + expect(globalPropsMode).toBe('event') + }) + test('environments.lynx.output.inlineScripts: false', async () => { const { pluginReactLynx } = await import('../src/pluginReactLynx.js') const rsbuild = await createRspeedy({ diff --git a/packages/webpack/react-refresh-webpack-plugin/package.json b/packages/webpack/react-refresh-webpack-plugin/package.json index 93d279eea7..15091be43f 100644 --- a/packages/webpack/react-refresh-webpack-plugin/package.json +++ b/packages/webpack/react-refresh-webpack-plugin/package.json @@ -48,7 +48,7 @@ "webpack": "^5.105.2" }, "peerDependencies": { - "@lynx-js/react-webpack-plugin": "^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0" + "@lynx-js/react-webpack-plugin": "^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0" }, "engines": { "node": ">=18" diff --git a/packages/webpack/react-refresh-webpack-plugin/test/setup-env.js b/packages/webpack/react-refresh-webpack-plugin/test/setup-env.js index 8df84f9117..a14d3a0ac5 100644 --- a/packages/webpack/react-refresh-webpack-plugin/test/setup-env.js +++ b/packages/webpack/react-refresh-webpack-plugin/test/setup-env.js @@ -12,6 +12,7 @@ function __injectGlobals(target) { target.__LEPUS__ = false; target.__REF_FIRE_IMMEDIATELY__ = false; target.__FIRST_SCREEN_SYNC_TIMING__ = 'immediately'; + target.__GLOBAL_PROPS_MODE__ = 'reactive'; target.lynx = { getJSModule: (moduleName) => { if (moduleName === 'GlobalEventEmitter') { 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 e7d777aa74..8db9b54706 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 @@ -49,6 +49,7 @@ export interface ReactWebpackPluginOptions { experimental_isLazyBundle?: boolean; extractStr?: Partial | boolean; firstScreenSyncTiming?: 'immediately' | 'jsReady'; + globalPropsMode?: 'reactive' | 'event'; mainThreadChunks?: string[] | undefined; profile?: boolean | undefined; workletRuntimePath: string; diff --git a/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts b/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts index af12ce91f5..f6d522a0e1 100644 --- a/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts +++ b/packages/webpack/react-webpack-plugin/src/ReactWebpackPlugin.ts @@ -33,6 +33,11 @@ interface ReactWebpackPluginOptions { */ firstScreenSyncTiming?: 'immediately' | 'jsReady'; + /** + * {@inheritdoc @lynx-js/react-rsbuild-plugin#PluginReactLynxOptions.globalPropsMode} + */ + globalPropsMode?: 'reactive' | 'event'; + /** * {@inheritdoc @lynx-js/react-rsbuild-plugin#PluginReactLynxOptions.enableSSR} */ @@ -137,6 +142,7 @@ class ReactWebpackPlugin { .freeze>({ disableCreateSelectorQueryIncompatibleWarning: false, firstScreenSyncTiming: 'immediately', + globalPropsMode: 'reactive', enableSSR: false, mainThreadChunks: [], extractStr: false, @@ -195,6 +201,7 @@ class ReactWebpackPlugin { __FIRST_SCREEN_SYNC_TIMING__: JSON.stringify( options.firstScreenSyncTiming, ), + __GLOBAL_PROPS_MODE__: JSON.stringify(options.globalPropsMode), __ENABLE_SSR__: JSON.stringify(options.enableSSR), __DISABLE_CREATE_SELECTOR_QUERY_INCOMPATIBLE_WARNING__: JSON.stringify( options.disableCreateSelectorQueryIncompatibleWarning, diff --git a/packages/webpack/react-webpack-plugin/test/cases/define/default/index.jsx b/packages/webpack/react-webpack-plugin/test/cases/define/default/index.jsx index 143a2aabc0..2d6f7d318a 100644 --- a/packages/webpack/react-webpack-plugin/test/cases/define/default/index.jsx +++ b/packages/webpack/react-webpack-plugin/test/cases/define/default/index.jsx @@ -1,6 +1,7 @@ it('should inject env variables', () => { /* eslint-disable */ expect(__FIRST_SCREEN_SYNC_TIMING__).toBe('immediately'); + expect(__GLOBAL_PROPS_MODE__).toBe('reactive'); expect(__EXTRACT_STR__).toBe(false); expect(__DISABLE_CREATE_SELECTOR_QUERY_INCOMPATIBLE_WARNING__).toBe(false); expect(__PROFILE__).toBe(false); diff --git a/packages/webpack/react-webpack-plugin/test/cases/define/global-props-mode-event/index.jsx b/packages/webpack/react-webpack-plugin/test/cases/define/global-props-mode-event/index.jsx new file mode 100644 index 0000000000..b6435ed5b0 --- /dev/null +++ b/packages/webpack/react-webpack-plugin/test/cases/define/global-props-mode-event/index.jsx @@ -0,0 +1,4 @@ +it('should inject env variables for event mode', () => { + /* eslint-disable */ + expect(__GLOBAL_PROPS_MODE__).toBe('event'); +}); diff --git a/packages/webpack/react-webpack-plugin/test/cases/define/global-props-mode-event/rspack.config.js b/packages/webpack/react-webpack-plugin/test/cases/define/global-props-mode-event/rspack.config.js new file mode 100644 index 0000000000..0b55090429 --- /dev/null +++ b/packages/webpack/react-webpack-plugin/test/cases/define/global-props-mode-event/rspack.config.js @@ -0,0 +1,7 @@ +import { createConfig } from '../../../create-react-config.js'; + +/** @type {import('@rspack/core').Configuration} */ +export default { + context: __dirname, + ...createConfig(undefined, { globalPropsMode: 'event' }), +}; diff --git a/packages/webpack/react-webpack-plugin/test/cases/define/global-props-mode-event/test.config.cjs b/packages/webpack/react-webpack-plugin/test/cases/define/global-props-mode-event/test.config.cjs new file mode 100644 index 0000000000..a9b8c33fb3 --- /dev/null +++ b/packages/webpack/react-webpack-plugin/test/cases/define/global-props-mode-event/test.config.cjs @@ -0,0 +1,7 @@ +/** @type {import("@lynx-js/test-tools").TConfigCaseConfig} */ +module.exports = { + bundlePath: [ + 'main__main-thread.js', + 'main__background.js', + ], +}; diff --git a/packages/webpack/react-webpack-plugin/test/setup-env.js b/packages/webpack/react-webpack-plugin/test/setup-env.js index 060d7dfe62..8f469a1544 100644 --- a/packages/webpack/react-webpack-plugin/test/setup-env.js +++ b/packages/webpack/react-webpack-plugin/test/setup-env.js @@ -9,6 +9,7 @@ function __injectGlobals(target) { target.__MAIN_THREAD__ = false; target.__REF_FIRE_IMMEDIATELY__ = false; target.__FIRST_SCREEN_SYNC_TIMING__ = 'immediately'; + target.__GLOBAL_PROPS_MODE__ = 'reactive'; target.lynx = {}; target.lynxCoreInject = {}; target.lynxCoreInject.tt = {};