diff --git a/.changeset/nice-needles-press.md b/.changeset/nice-needles-press.md new file mode 100644 index 0000000000..1aff40f798 --- /dev/null +++ b/.changeset/nice-needles-press.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/rspeedy": patch +--- + +Support CLI flag `--mode` to specify the build mode. diff --git a/.changeset/tired-drinks-give.md b/.changeset/tired-drinks-give.md new file mode 100644 index 0000000000..52f5229d3f --- /dev/null +++ b/.changeset/tired-drinks-give.md @@ -0,0 +1,10 @@ +--- +"@lynx-js/react": minor +--- + +Fixed closure variable capture issue in effect hooks to prevent stale values and ensured proper execution order between refs, effects, and event handlers. + +**Breaking Changes**: + +- The execution timing of `ref` and `useEffect()` side effects has been moved forward. These effects will now execute before hydration is complete, rather than waiting for the main thread update to complete. +- For components inside ``, `ref` callbacks will now be triggered during background thread rendering, regardless of component visibility. If your code depends on component visibility timing, use `main-thread:ref` instead of regular `ref`. diff --git a/.changeset/wild-sheep-dream.md b/.changeset/wild-sheep-dream.md new file mode 100644 index 0000000000..a71a91af9b --- /dev/null +++ b/.changeset/wild-sheep-dream.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/tailwind-preset": patch +--- + +Support `hidden`, `no-underline` and `line-through` utilities. diff --git a/packages/react/runtime/__test__/delayed-lifecycle-events.test.jsx b/packages/react/runtime/__test__/delayed-lifecycle-events.test.jsx index b5694e293d..2b7dbeed9e 100644 --- a/packages/react/runtime/__test__/delayed-lifecycle-events.test.jsx +++ b/packages/react/runtime/__test__/delayed-lifecycle-events.test.jsx @@ -31,7 +31,6 @@ describe('delayedLifecycleEvents', () => { "rLynxFirstScreen", { "jsReadyEventIdSwap": {}, - "refPatch": "{}", "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__Card__:__snapshot_a94a8_test_1"}]}", }, ], @@ -45,7 +44,6 @@ describe('delayedLifecycleEvents', () => { "rLynxFirstScreen", { "jsReadyEventIdSwap": {}, - "refPatch": "{}", "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__Card__:__snapshot_a94a8_test_1"}]}", }, ], diff --git a/packages/react/runtime/__test__/lifecycle.test.jsx b/packages/react/runtime/__test__/lifecycle.test.jsx index bfb64ad1e5..a7684a8150 100644 --- a/packages/react/runtime/__test__/lifecycle.test.jsx +++ b/packages/react/runtime/__test__/lifecycle.test.jsx @@ -42,243 +42,21 @@ describe('useEffect', () => { } initGlobalSnapshotPatch(); - let mtCallbacks = lynx.getNativeApp().callLepusMethod; globalEnvManager.switchToBackground(); render(, __root); - expect(callback).toHaveBeenCalledTimes(0); - expect(cleanUp).toHaveBeenCalledTimes(0); - - expect(callback).toHaveBeenCalledTimes(0); - expect(mtCallbacks.mock.calls.length).toBe(1); - mtCallbacks.mock.calls[0][2](); - lynx.getNativeApp().callLepusMethod.mockClear(); - expect(callback).toHaveBeenCalledTimes(1); - expect(cleanUp).toHaveBeenCalledTimes(0); await waitSchedule(); - expect(callback).toHaveBeenCalledTimes(1); - expect(cleanUp).toHaveBeenCalledTimes(0); - render(, __root); expect(callback).toHaveBeenCalledTimes(1); expect(cleanUp).toHaveBeenCalledTimes(0); - expect(mtCallbacks.mock.calls.length).toBe(1); - mtCallbacks.mock.calls[0][2](); - lynx.getNativeApp().callLepusMethod.mockClear(); - - await waitSchedule(); - expect(callback).toHaveBeenCalledTimes(2); - expect(cleanUp).toHaveBeenCalledTimes(1); - }); - - it('should call after main thread returns', async function() { - globalEnvManager.switchToBackground(); - - let mtCallbacks = lynx.getNativeApp().callLepusMethod.mock.calls; - - const cleanUp = vi.fn(); - const callback = vi.fn().mockImplementation(() => cleanUp); - - function Comp() { - const [val, setVal] = useState(1); - useLayoutEffect(callback); - return {val}; - } - - initGlobalSnapshotPatch(); - - render(, __root); render(, __root); - render(, __root); - expect(callback).toHaveBeenCalledTimes(0); - expect(cleanUp).toHaveBeenCalledTimes(0); - let mtCallback; - // expect(mtCallbacks.length).toEqual(3); - mtCallback = mtCallbacks.shift(); - expect(mtCallback[0]).toEqual(LifecycleConstant.patchUpdate); - expect(mtCallback[1]).toMatchInlineSnapshot(` - { - "data": "{"patchList":[{"id":3,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_2",2,0,null,3,3,3,0,1,1,2,3,null,1,1,2,null]}]}", - "patchOptions": { - "reloadVersion": 0, - }, - } - `); - mtCallback[2](); await waitSchedule(); - expect(callback).toHaveBeenCalledTimes(1); - expect(cleanUp).toHaveBeenCalledTimes(0); - expect(mtCallbacks.length).toEqual(2); - mtCallback = mtCallbacks.shift(); - expect(mtCallback[0]).toEqual(LifecycleConstant.patchUpdate); - expect(mtCallback[1]).toMatchInlineSnapshot(` - { - "data": "{"patchList":[{"id":4}]}", - "patchOptions": { - "reloadVersion": 0, - }, - } - `); - mtCallback[2](); - await waitSchedule(); expect(callback).toHaveBeenCalledTimes(2); expect(cleanUp).toHaveBeenCalledTimes(1); - - expect(mtCallbacks.length).toEqual(1); - mtCallback = mtCallbacks.shift(); - expect(mtCallback[0]).toEqual(LifecycleConstant.patchUpdate); - expect(mtCallback[1]).toMatchInlineSnapshot(` - { - "data": "{"patchList":[{"id":5}]}", - "patchOptions": { - "reloadVersion": 0, - }, - } - `); - mtCallback[2](); - await waitSchedule(); - expect(callback).toHaveBeenCalledTimes(3); - expect(cleanUp).toHaveBeenCalledTimes(2); - }); - - it('change before hydration', async function() { - let setVal_; - - const cleanUp = vi.fn(); - const callback = vi.fn(() => { - return cleanUp; - }); - - function Comp() { - const [val, setVal] = useState(1); - setVal_ = setVal; - useLayoutEffect(callback); - return {val}; - } - - // main thread render - { - __root.__jsx = ; - renderPage(); - } - - // background render - { - globalEnvManager.switchToBackground(); - render(, __root); - } - - // background state change - { - setVal_(300); - await waitSchedule(); - expect(lynx.getNativeApp().callLepusMethod).not.toBeCalled(); - } - - // background state change - { - setVal_(400); - await waitSchedule(); - expect(lynx.getNativeApp().callLepusMethod).not.toBeCalled(); - } - - // hydrate - { - // LifecycleConstant.firstScreen - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); - expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( - `"{"patchList":[{"snapshotPatch":[3,-3,0,400],"id":9}]}"`, - ); - globalThis.__OnLifecycleEvent.mockClear(); - - await waitSchedule(); - expect(callback).toHaveBeenCalledTimes(0); - expect(cleanUp).toHaveBeenCalledTimes(0); - - // rLynxChange - globalEnvManager.switchToMainThread(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - rLynxChange[2](); - - await waitSchedule(); - expect(callback).toHaveBeenCalledTimes(3); - expect(cleanUp).toHaveBeenCalledTimes(2); - } - }); - - it('cleanup function should delay when unmounts', async function() { - const cleanUp = vi.fn(); - const callback = vi.fn(() => { - return cleanUp; - }); - - function A() { - useLayoutEffect(callback); - } - - function Comp(props) { - return props.show && ; - } - - // main thread render - { - __root.__jsx = ; - renderPage(); - } - - // background render - { - globalEnvManager.switchToBackground(); - render(, __root); - } - - // hydrate - { - // LifecycleConstant.firstScreen - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - globalThis.__OnLifecycleEvent.mockClear(); - - await waitSchedule(); - expect(callback).toHaveBeenCalledTimes(0); - expect(cleanUp).toHaveBeenCalledTimes(0); - - // rLynxChange - globalEnvManager.switchToMainThread(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - rLynxChange[2](); - await waitSchedule(); - } - - // background unmount - { - globalEnvManager.switchToBackground(); - lynx.getNativeApp().callLepusMethod.mockClear(); - render(, __root); - render(, __root); - expect(callback).toHaveBeenCalledTimes(0); - expect(cleanUp).toHaveBeenCalledTimes(0); - } - - { - expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(2); - let rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - rLynxChange[2](); - await waitSchedule(); - expect(callback).toHaveBeenCalledTimes(1); - expect(cleanUp).toHaveBeenCalledTimes(0); - - rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[1]; - rLynxChange[2](); - await waitSchedule(); - expect(callback).toHaveBeenCalledTimes(1); - expect(cleanUp).toHaveBeenCalledTimes(1); - } }); it('throw', async function() { @@ -287,8 +65,6 @@ describe('useEffect', () => { const catchError = options[CATCH_ERROR]; options[CATCH_ERROR] = vi.fn(); - let mtCallbacks = lynx.getNativeApp().callLepusMethod.mock.calls; - const callback = vi.fn().mockImplementation(() => { throw '???'; }); @@ -303,23 +79,10 @@ describe('useEffect', () => { render(, __root); render(, __root); render(, __root); - expect(callback).toHaveBeenCalledTimes(0); + expect(callback).toHaveBeenCalledTimes(2); - let mtCallback; - expect(mtCallbacks.length).toEqual(3); - mtCallback = mtCallbacks.shift(); - expect(mtCallback[0]).toEqual(LifecycleConstant.patchUpdate); - expect(mtCallback[1]).toMatchInlineSnapshot(` - { - "data": "{"patchList":[{"id":14,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_4",2,0,null,3,3,3,0,1,1,2,3,null,1,1,2,null]}]}", - "patchOptions": { - "reloadVersion": 0, - }, - } - `); - mtCallback[2](); await waitSchedule(); - expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(3); expect(options[CATCH_ERROR]).toHaveBeenCalledWith('???', expect.anything()); options[CATCH_ERROR] = catchError; }); @@ -359,7 +122,7 @@ describe('componentDidMount', () => { expect(mtCallback[0]).toEqual(LifecycleConstant.patchUpdate); expect(mtCallback[1]).toMatchInlineSnapshot(` { - "data": "{"patchList":[{"id":17,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_5",2,0,null,3,3,3,0,1,1,2,3,null,1,1,2,null]}]}", + "data": "{"patchList":[{"id":6,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_3",2,0,null,3,3,3,0,1,1,2,3,null,1,1,2,null]}]}", "patchOptions": { "reloadVersion": 0, }, @@ -406,7 +169,7 @@ describe('componentDidMount', () => { expect(mtCallback[0]).toEqual(LifecycleConstant.patchUpdate); expect(mtCallback[1]).toMatchInlineSnapshot(` { - "data": "{"patchList":[{"id":20,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_6",2,0,null,3,3,3,0,1,1,2,3,null,1,1,2,null]}]}", + "data": "{"patchList":[{"id":9,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_4",2,0,null,3,3,3,0,1,1,2,3,null,1,1,2,null]}]}", "patchOptions": { "reloadVersion": 0, }, @@ -739,7 +502,7 @@ describe('useState', () => { await waitSchedule(); expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( - `"{"patchList":[{"id":36,"snapshotPatch":[3,-2,1,"abcd",3,-2,2,{"str":"efgh"}]}]}"`, + `"{"patchList":[{"id":25,"snapshotPatch":[3,-2,1,"abcd",3,-2,2,{"str":"efgh"}]}]}"`, ); } }); @@ -797,7 +560,7 @@ describe('useState', () => { await waitSchedule(); expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( - `"{"patchList":[{"id":39,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_17",2,4,2,[false,{"str":"str"}],1,-1,2,null]}]}"`, + `"{"patchList":[{"id":28,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_15",2,4,2,[false,{"str":"str"}],1,-1,2,null]}]}"`, ); } }); diff --git a/packages/react/runtime/__test__/lifecycle/reload.test.jsx b/packages/react/runtime/__test__/lifecycle/reload.test.jsx index eb7203c291..f811d42173 100644 --- a/packages/react/runtime/__test__/lifecycle/reload.test.jsx +++ b/packages/react/runtime/__test__/lifecycle/reload.test.jsx @@ -301,7 +301,6 @@ describe('reload', () => { [ "rLynxFirstScreen", { - "refPatch": "{}", "root": "{"id":-9,"type":"root","children":[{"id":-13,"type":"__Card__:__snapshot_a94a8_test_2","values":[{"dataX":"WorldX"}],"children":[{"id":-10,"type":"__Card__:__snapshot_a94a8_test_3","children":[{"id":-15,"type":null,"values":["Enjoy"]}]},{"id":-11,"type":"__Card__:__snapshot_a94a8_test_4","children":[{"id":-16,"type":null,"values":["World"]}]},{"id":-12,"type":"wrapper","children":[{"id":-14,"type":"__Card__:__snapshot_a94a8_test_1","values":[{"attr":{"dataX":"WorldX"}}]}]}]}]}", }, ], @@ -711,7 +710,6 @@ describe('reload', () => { [ "rLynxFirstScreen", { - "refPatch": "{}", "root": "{"id":-9,"type":"root","children":[{"id":-15,"type":"__Card__:__snapshot_a94a8_test_5","children":[{"id":-13,"type":"__Card__:__snapshot_a94a8_test_2","values":[{"dataX":"WorldX"}],"children":[{"id":-10,"type":"__Card__:__snapshot_a94a8_test_3","children":[{"id":-16,"type":null,"values":["Enjoy"]}]},{"id":-11,"type":"__Card__:__snapshot_a94a8_test_4","children":[{"id":-17,"type":null,"values":["World"]}]},{"id":-12,"type":"wrapper","children":[{"id":-14,"type":"__Card__:__snapshot_a94a8_test_1","values":[{"attr":{"dataX":"WorldX"}}]}]}]}]}]}", }, ], @@ -1314,7 +1312,6 @@ describe('firstScreenSyncTiming - jsReady', () => { "-8": -16, "-9": -17, }, - "refPatch": "{}", "root": "{"id":-17,"type":"root","children":[{"id":-21,"type":"__Card__:__snapshot_a94a8_test_2","values":[{"dataX":"WorldX"}],"children":[{"id":-18,"type":"__Card__:__snapshot_a94a8_test_3","children":[{"id":-23,"type":null,"values":["Hello 2"]}]},{"id":-19,"type":"__Card__:__snapshot_a94a8_test_4","children":[{"id":-24,"type":null,"values":["World"]}]},{"id":-20,"type":"wrapper","children":[{"id":-22,"type":"__Card__:__snapshot_a94a8_test_1","values":[{"attr":{"dataX":"WorldX"}}]}]}]}]}", }, ], @@ -1518,7 +1515,6 @@ describe('firstScreenSyncTiming - jsReady', () => { "-5": -13, "-9": -17, }, - "refPatch": "{}", "root": "{"id":-17,"type":"root","children":[{"id":-21,"type":"__Card__:__snapshot_a94a8_test_7","children":[{"id":-18,"type":"__Card__:__snapshot_a94a8_test_8","values":[{"item-key":0}],"children":[{"id":-22,"type":"__Card__:__snapshot_a94a8_test_6","values":["a"]}]},{"id":-19,"type":"__Card__:__snapshot_a94a8_test_8","values":[{"item-key":1}],"children":[{"id":-23,"type":"__Card__:__snapshot_a94a8_test_6","values":["b"]}]},{"id":-20,"type":"__Card__:__snapshot_a94a8_test_8","values":[{"item-key":2}],"children":[{"id":-24,"type":"__Card__:__snapshot_a94a8_test_6","values":["c"]}]}]}]}", }, ], @@ -1683,7 +1679,6 @@ describe('firstScreenSyncTiming - jsReady', () => { "-2": -10, "-6": -14, }, - "refPatch": "{}", "root": "{"id":-10,"type":"root","children":[{"id":-14,"type":"__Card__:__snapshot_a94a8_test_9","children":[{"id":-11,"type":"__Card__:__snapshot_a94a8_test_10","values":[{"item-key":0}],"children":[{"id":-15,"type":"__Card__:__snapshot_a94a8_test_6","values":["a"]}]},{"id":-12,"type":"__Card__:__snapshot_a94a8_test_10","values":[{"item-key":1}],"children":[{"id":-16,"type":"__Card__:__snapshot_a94a8_test_6","values":["b"]}]},{"id":-13,"type":"__Card__:__snapshot_a94a8_test_10","values":[{"item-key":2}],"children":[{"id":-17,"type":"__Card__:__snapshot_a94a8_test_6","values":["c"]}]}]}]}", }, ], @@ -1877,7 +1872,6 @@ describe('firstScreenSyncTiming - jsReady', () => { "rLynxFirstScreen", { "jsReadyEventIdSwap": {}, - "refPatch": "{}", "root": "{"id":-17,"type":"root","children":[{"id":-21,"type":"__Card__:__snapshot_a94a8_test_2","values":[{"dataX":"WorldX"}],"children":[{"id":-18,"type":"__Card__:__snapshot_a94a8_test_3","children":[{"id":-23,"type":null,"values":["Hello 2"]}]},{"id":-19,"type":"__Card__:__snapshot_a94a8_test_4","children":[{"id":-24,"type":null,"values":["World"]}]},{"id":-20,"type":"wrapper","children":[{"id":-22,"type":"__Card__:__snapshot_a94a8_test_1","values":[{"attr":{"dataX":"WorldX"}}]}]}]}]}", }, ], diff --git a/packages/react/runtime/__test__/page.test.jsx b/packages/react/runtime/__test__/page.test.jsx index 1a94120aec..d08f41c0f3 100644 --- a/packages/react/runtime/__test__/page.test.jsx +++ b/packages/react/runtime/__test__/page.test.jsx @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + import { __page } from '../src/snapshot'; -import { elementTree } from './utils/nativeMethod'; import { globalEnvManager } from './utils/envManager'; +import { elementTree } from './utils/nativeMethod'; +import { useRef, useState } from '../src/index'; import { __root } from '../src/root'; -import { useState, useRef } from '../src/index'; beforeEach(() => { globalEnvManager.resetEnv(); @@ -77,7 +78,7 @@ describe('support element attributes', () => { "bindEvent:tap": "-1:0:bindtap", } } - has-react-ref={true} + react-ref--1-0={1} > @@ -315,6 +316,7 @@ describe('support element attributes', () => { expect(__root.__element_root).toMatchInlineSnapshot(` { "-5": -8, "-6": -9, }, - "refPatch": "{}", "root": "{"id":-7,"type":"root","children":[{"id":-8,"type":"__Card__:__snapshot_a94a8_test_12","children":[{"id":-9,"type":"__Card__:__snapshot_a94a8_test_11","values":["-9:0:"]}]}]}", }, ], @@ -1272,7 +1271,6 @@ describe('call `root.render()` async', () => { "rLynxFirstScreen", { "jsReadyEventIdSwap": {}, - "refPatch": "{}", "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__Card__:__snapshot_a94a8_test_14","children":[{"id":-3,"type":"__Card__:__snapshot_a94a8_test_13","values":["-3:0:"]}]}]}", }, ], diff --git a/packages/react/runtime/__test__/snapshot/ref.test.jsx b/packages/react/runtime/__test__/snapshot/ref.test.jsx index 66e1251f8c..a8b1f1fb09 100644 --- a/packages/react/runtime/__test__/snapshot/ref.test.jsx +++ b/packages/react/runtime/__test__/snapshot/ref.test.jsx @@ -6,8 +6,7 @@ import { render } from 'preact'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Component, createRef, root, useState } from '../../src/index'; -import { delayedLifecycleEvents } from '../../src/lifecycle/event/delayLifecycleEvents'; +import { Component, createRef, useState } from '../../src/index'; import { clearCommitTaskId, replaceCommitHook } from '../../src/lifecycle/patch/commit'; import { injectUpdateMainThread } from '../../src/lifecycle/patch/updateMainThread'; import { __pendingListUpdates } from '../../src/list'; @@ -102,10 +101,10 @@ describe('element ref', () => { > @@ -116,8 +115,7 @@ describe('element ref', () => { "rLynxFirstScreen", { "jsReadyEventIdSwap": {}, - "refPatch": "{"-2:0:":3,"-2:1:":4}", - "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__Card__:__snapshot_a94a8_test_3","values":["-2:0:","-2:1:"]}]}", + "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__Card__:__snapshot_a94a8_test_3","values":["react-ref--2-0","react-ref--2-1"]}]}", }, ], ] @@ -128,50 +126,33 @@ describe('element ref', () => { { globalEnvManager.switchToBackground(); render(, __root); - expect(ref1).not.toBeCalled(); - expect(ref2.current).toBe(null); - } - - // hydrate - { - // LifecycleConstant.firstScreen - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); - expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( - `"{"patchList":[{"snapshotPatch":[],"id":2}]}"`, - ); - lynx.getNativeApp().callLepusMethod.mock.calls[0][2](); - await waitSchedule(); expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - { - "selectUniqueID": [Function], - "uid": 3, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], ] `); - expect(ref2).toMatchInlineSnapshot(` - { - "current": { - "selectUniqueID": [Function], - "uid": 4, - }, + expect(ref2.current).toMatchInlineSnapshot(` + RefProxy { + "refAttr": [ + 2, + 1, + ], + "task": undefined, } `); - - // rLynxChange - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); } }); - it('insert', async function() { + it('should trigger ref when insert node', async function() { const ref1 = vi.fn(); const ref2 = createRef(); @@ -219,69 +200,49 @@ describe('element ref', () => { render(, __root); expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( - `"{"patchList":[{"id":3,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_4",2,4,2,[3,4],1,-1,2,null]}]}"`, + `"{"patchList":[{"id":3,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_4",2,4,2,[1,1],1,-1,2,null]}]}"`, ); } // rLynxChange { globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; globalThis[rLynxChange[0]](rLynxChange[1]); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); rLynxChange[2](); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` - [ - [ - [ - "rLynxRef", - { - "commitTaskId": 3, - "refPatch": "{"2:0:":7,"2:1:":8}", - }, - ], - ], - ] - `); } // ref { globalEnvManager.switchToBackground(); - await waitSchedule(); expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - { - "selectUniqueID": [Function], - "uid": 7, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], ] `); expect(ref2).toMatchInlineSnapshot(` { - "current": { - "selectUniqueID": [Function], - "uid": 8, + "current": RefProxy { + "refAttr": [ + 2, + 1, + ], + "task": undefined, }, } `); } - - { - globalEnvManager.switchToBackground(); - lynx.getNativeApp().callLepusMethod.mockClear(); - render(, __root); - expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); - expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( - `"{"patchList":[{"id":4,"snapshotPatch":[3,2,0,3,3,2,1,4]}]}"`, - ); - } }); - it('remove', async function() { + it('should trigger ref when remove node', async function() { const ref1 = vi.fn(); const ref2 = createRef(); @@ -334,35 +295,9 @@ describe('element ref', () => { ); } - // rLynxChange - { - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` - [ - [ - [ - "rLynxRef", - { - "commitTaskId": 3, - "refPatch": "{"-2:0:":null,"-2:1:":null}", - }, - ], - ], - ] - `); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - globalThis.__OnLifecycleEvent.mockClear(); - rLynxChange[2](); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`[]`); - } - // ref patch { globalEnvManager.switchToBackground(); - await waitSchedule(); expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ @@ -374,7 +309,7 @@ describe('element ref', () => { } }); - it('remove with cleanup function', async function() { + it('should trigger ref when remove node with cleanup function', async function() { const cleanup = vi.fn(); const ref1 = vi.fn(() => { return cleanup; @@ -428,33 +363,9 @@ describe('element ref', () => { ); } - // rLynxChange - { - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - rLynxChange[2](); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` - [ - [ - [ - "rLynxRef", - { - "commitTaskId": 3, - "refPatch": "{"-2:0:":null}", - }, - ], - ], - ] - `); - } - // ref patch { globalEnvManager.switchToBackground(); - await waitSchedule(); expect(ref1).not.toBeCalled(); expect(cleanup.mock.calls).toMatchInlineSnapshot(` [ @@ -464,7 +375,7 @@ describe('element ref', () => { } }); - it('callback should ref and unref deeply', async () => { + it('should trigger ref when ref and unref deeply', async () => { const ref1 = [vi.fn(), vi.fn(), vi.fn()]; const ref2 = vi.fn(); let _setShow; @@ -509,16 +420,15 @@ describe('element ref', () => { globalThis.__OnLifecycleEvent.mockClear(); const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent).not.toBeCalled(); } ref1.forEach(ref => { expect(ref).toHaveBeenCalledWith(expect.objectContaining({ - uid: expect.any(Number), + refAttr: expect.any(Array), })); }); expect(ref2).toHaveBeenCalledWith(expect.objectContaining({ - uid: expect.any(Number), + refAttr: expect.any(Array), })); ref1.forEach(ref => ref.mockClear()); ref2.mockClear(); @@ -531,39 +441,15 @@ describe('element ref', () => { await waitSchedule(); } - // rLynxChange - { - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` - [ - [ - [ - "rLynxRef", - { - "commitTaskId": 3, - "refPatch": "{"-2:0:":null,"-3:0:":null,"-4:0:":null,"-5:0:":null}", - }, - ], - ], - ] - `); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - rLynxChange[2](); - } - - // ref patch + // ref check { globalEnvManager.switchToBackground(); - await waitSchedule(); ref1.forEach(ref => expect(ref).toHaveBeenCalledWith(null)); expect(ref2).toHaveBeenCalledWith(null); } }); - it('hydrate', async function() { + it('should trigger ref when ref is null in the first screen', async function() { const ref1 = createRef(); const ref2 = createRef(); const ref3 = vi.fn(); @@ -591,10 +477,14 @@ describe('element ref', () => { > + + - - `); @@ -604,8 +494,7 @@ describe('element ref', () => { "rLynxFirstScreen", { "jsReadyEventIdSwap": {}, - "refPatch": "{"-2:0:":23}", - "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__Card__:__snapshot_a94a8_test_9","values":["-2:0:",null,null]}]}", + "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__Card__:__snapshot_a94a8_test_9","values":["react-ref--2-0","react-ref--2-1","react-ref--2-2"]}]}", }, ], ] @@ -617,6 +506,30 @@ describe('element ref', () => { globalEnvManager.switchToBackground(); render(, __root); lynx.getNativeApp().callLepusMethod.mockClear(); + + expect(ref1.current).toBeNull(); + expect(ref2.current).toMatchInlineSnapshot(` + RefProxy { + "refAttr": [ + 2, + 1, + ], + "task": undefined, + } + `); + expect(ref3.mock.calls).toMatchInlineSnapshot(` + [ + [ + RefProxy { + "refAttr": [ + 2, + 2, + ], + "task": undefined, + }, + ], + ] + `); } // hydrate @@ -625,135 +538,36 @@ describe('element ref', () => { lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( - `"{"patchList":[{"snapshotPatch":[3,-2,0,null,3,-2,1,13,3,-2,2,14],"id":2}]}"`, + `"{"patchList":[{"snapshotPatch":[3,-2,0,null],"id":2}]}"`, ); - expect(ref1.current).toBeNull(); - expect(ref2.current).toBeNull(); - expect(ref3).not.toBeCalled(); - // rLynxChange globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; globalThis[rLynxChange[0]](rLynxChange[1]); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); rLynxChange[2](); + // Keep "-2:0" exists even if it is set to null. This is for the first screen. expect(__root.__element_root).toMatchInlineSnapshot(` `); - - // ref patch - { - globalEnvManager.switchToBackground(); - await waitSchedule(); - expect(ref1.current).toBeNull(); - expect(ref2).toMatchInlineSnapshot(` - { - "current": { - "selectUniqueID": [Function], - "uid": 24, - }, - } - `); - expect(ref3.mock.calls).toMatchInlineSnapshot(` - [ - [ - { - "selectUniqueID": [Function], - "uid": 25, - }, - ], - ] - `); - } - } - }); - - it('change before hydration', async function() { - const ref1 = createRef(); - const ref2 = createRef(); - - class Comp extends Component { - x = 'x'; - render() { - return ( - - - - - ); - } - } - - // main thread render - { - __root.__jsx = ; - renderPage(); - } - - // background render - { - globalEnvManager.switchToBackground(); - render(, __root); - lynx.getNativeApp().callLepusMethod.mockClear(); - } - - // background state change - { - render(, __root); - expect(lynx.getNativeApp().callLepusMethod).not.toBeCalled(); - } - - // hydrate - { - // LifecycleConstant.firstScreen - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); - expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( - `"{"patchList":[{"snapshotPatch":[3,-2,0,null,3,-2,1,16],"id":3}]}"`, - ); - globalThis.__OnLifecycleEvent.mockClear(); - - // rLynxChange - globalEnvManager.switchToMainThread(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - rLynxChange[2](); - - // ref patch - { - globalEnvManager.switchToBackground(); - await waitSchedule(); - expect(ref1.current).toBeNull(); - expect(ref2).toMatchInlineSnapshot(` - { - "current": { - "selectUniqueID": [Function], - "uid": 29, - }, - } - `); - } } }); - it('wrong ref type', async function() { + it('should throw error when ref type is wrong', async function() { let ref1 = 1; class Comp extends Component { @@ -791,7 +605,7 @@ describe('element ref', () => { } }); - it('update', async function() { + it('should trigger ref when ref object is updated', async function() { const cleanup = vi.fn(); let ref1 = vi.fn(() => { return cleanup; @@ -800,7 +614,6 @@ describe('element ref', () => { let ref3 = createRef(); class Comp extends Component { - x = 'x'; render() { return ( @@ -850,52 +663,34 @@ describe('element ref', () => { render(, __root); expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( - `"{"patchList":[{"id":3,"snapshotPatch":[3,-2,0,20,3,-2,1,21,3,-2,2,null]}]}"`, + `"{"patchList":[{"id":3,"snapshotPatch":[3,-2,2,null]}]}"`, ); } - // rLynxChange - { - globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - rLynxChange[2](); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` - [ - [ - [ - "rLynxRef", - { - "commitTaskId": 3, - "refPatch": "{"-2:0:":32,"-2:1:":33}", - }, - ], - ], - ] - `); - } - // ref { globalEnvManager.switchToBackground(); - await waitSchedule(); expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - { - "selectUniqueID": [Function], - "uid": 32, + RefProxy { + "refAttr": [ + -2, + 0, + ], + "task": undefined, }, ], ] `); expect(ref2).toMatchInlineSnapshot(` { - "current": { - "selectUniqueID": [Function], - "uid": 33, + "current": RefProxy { + "refAttr": [ + -2, + 1, + ], + "task": undefined, }, } `); @@ -909,24 +704,14 @@ describe('element ref', () => { expect(oldRef3.current).toBeNull(); } }); -}); -describe('element ref in spread', () => { - it('insert', async function() { - const ref1 = vi.fn(); - const ref2 = createRef(); - let spread1 = {}; - const spread2 = { ref: ref2 }; + it('should work when using ref along with other attributes', async function() { + const ref = createRef(); + const attr1 = 1; class Comp extends Component { - x = 'x'; render() { - return ( - - - - - ); + return ; } } @@ -934,59 +719,107 @@ describe('element ref in spread', () => { { __root.__jsx = ; renderPage(); - expect(__root.__element_root).toMatchInlineSnapshot(` - - - - - - - `); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` - [ - [ - [ - "rLynxFirstScreen", - { - "jsReadyEventIdSwap": {}, - "refPatch": "{"-2:1:ref":38}", - "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__Card__:__snapshot_a94a8_test_13","values":[{},{"ref":"-2:1:ref"}]}]}", - }, - ], - ], - ] - `); } // background render { globalEnvManager.switchToBackground(); render(, __root); - lynx.getNativeApp().callLepusMethod.mockClear(); + expect(ref.current).toMatchInlineSnapshot(` + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, + } + `); } + }); - // hydrate + // NOT working for now + it.skip('should work when using error boundary with ref', async function() { + const ref = vi.fn(() => { + throw new Error('error in ref'); + }); + const errorHandler = vi.fn(); + + class Comp extends Component { + state = { hasError: false }; + + componentDidCatch(error, info) { + errorHandler(error, info); + this.setState({ hasError: true }); + } + + render() { + if (this.state.hasError) { + return Error occurred; + } + return ; + } + } + + // main thread render { - // LifecycleConstant.firstScreen - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - globalThis.__OnLifecycleEvent.mockClear(); + __root.__jsx = ; + renderPage(); + } - // rLynxChange - globalEnvManager.switchToMainThread(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); + // background render + { + globalEnvManager.switchToBackground(); + render(, __root); + expect(ref.current).toMatchInlineSnapshot(`undefined`); + + expect(errorHandler).toHaveBeenCalledTimes(1); + } + }); +}); + +describe('element ref in spread', () => { + it('should trigger ref when insert ref into spread', async function() { + const ref1 = vi.fn(); + const ref2 = createRef(); + let spread1 = {}; + const spread2 = { ref: ref2 }; + + class Comp extends Component { + x = 'x'; + render() { + return ( + + + + + ); + } + } + + // main thread render + { + __root.__jsx = ; + renderPage(); + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + + `); expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` [ [ [ - "rLynxRef", + "rLynxFirstScreen", { - "commitTaskId": 2, - "refPatch": "{"-2:1:ref":38}", + "jsReadyEventIdSwap": {}, + "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__Card__:__snapshot_a94a8_test_15","values":[{},{"ref":"react-ref--2-1"}]}]}", }, ], ], @@ -994,15 +827,36 @@ describe('element ref in spread', () => { `); } - // ref + // background render { + globalEnvManager.switchToBackground(); + render(, __root); + lynx.getNativeApp().callLepusMethod.mockClear(); + } + + // hydrate + { + // LifecycleConstant.firstScreen lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + globalThis.__OnLifecycleEvent.mockClear(); + + // rLynxChange + globalEnvManager.switchToMainThread(); + const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; + globalThis[rLynxChange[0]](rLynxChange[1]); + } + + // ref + { expect(ref1.mock.calls).toMatchInlineSnapshot(`[]`); expect(ref2).toMatchInlineSnapshot(` { - "current": { - "selectUniqueID": [Function], - "uid": 38, + "current": RefProxy { + "refAttr": [ + 2, + 1, + ], + "task": undefined, }, } `); @@ -1016,43 +870,44 @@ describe('element ref in spread', () => { render(, __root); expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( - `"{"patchList":[{"id":3,"snapshotPatch":[3,-2,0,{"ref":23}]}]}"`, + `"{"patchList":[{"id":3,"snapshotPatch":[3,-2,0,{"ref":1}]}]}"`, ); } // rLynxChange { globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; globalThis[rLynxChange[0]](rLynxChange[1]); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); rLynxChange[2](); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` - [ - [ - [ - "rLynxRef", - { - "commitTaskId": 3, - "refPatch": "{"-2:0:ref":37}", - }, - ], - ], - ] + expect(__root.__element_root).toMatchInlineSnapshot(` + + + + + + `); } // ref { globalEnvManager.switchToBackground(); - await waitSchedule(); expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - { - "selectUniqueID": [Function], - "uid": 37, + RefProxy { + "refAttr": [ + -2, + 0, + ], + "task": undefined, }, ], ] @@ -1060,7 +915,7 @@ describe('element ref in spread', () => { } }); - it('remove', async function() { + it('should trigger ref when remove ref from spread', async function() { const ref1 = vi.fn(); const ref2 = createRef(); let spread1 = { ref: ref1 }; @@ -1099,18 +954,24 @@ describe('element ref in spread', () => { expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - { - "selectUniqueID": [Function], - "uid": 43, + RefProxy { + "refAttr": [ + 3, + 0, + ], + "task": undefined, }, ], ] `); expect(ref2).toMatchInlineSnapshot(` { - "current": { - "selectUniqueID": [Function], - "uid": 42, + "current": RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, } `); @@ -1123,8 +984,6 @@ describe('element ref in spread', () => { // ref { - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - globalThis.__OnLifecycleEvent.mockClear(); ref1.mockClear(); } @@ -1143,16 +1002,13 @@ describe('element ref in spread', () => { // rLynxChange { globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; globalThis[rLynxChange[0]](rLynxChange[1]); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); rLynxChange[2](); } // ref { - await waitSchedule(); expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ @@ -1164,7 +1020,7 @@ describe('element ref in spread', () => { } }); - it('update', async function() { + it('should trigger ref when update ref in spread', async function() { let ref1 = vi.fn(); let ref2 = createRef(); let ref3 = createRef(); @@ -1206,26 +1062,35 @@ describe('element ref in spread', () => { expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - { - "selectUniqueID": [Function], - "uid": 46, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], ] `); expect(ref2).toMatchInlineSnapshot(` { - "current": { - "selectUniqueID": [Function], - "uid": 47, + "current": RefProxy { + "refAttr": [ + 2, + 1, + ], + "task": undefined, }, } `); expect(ref3).toMatchInlineSnapshot(` { - "current": { - "selectUniqueID": [Function], - "uid": 48, + "current": RefProxy { + "refAttr": [ + 2, + 2, + ], + "task": undefined, }, } `); @@ -1242,8 +1107,6 @@ describe('element ref in spread', () => { // ref { globalEnvManager.switchToBackground(); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - globalThis.__OnLifecycleEvent.mockClear(); ref1.mockClear(); } @@ -1262,52 +1125,43 @@ describe('element ref in spread', () => { render(, __root); expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( - `"{"patchList":[{"id":3,"snapshotPatch":[3,-2,0,{"ref":29},3,-2,1,{"ref":30},3,-2,2,{}]}]}"`, + `"{"patchList":[{"id":3,"snapshotPatch":[3,-2,2,{}]}]}"`, ); } // rLynxChange { globalEnvManager.switchToMainThread(); - globalThis.__OnLifecycleEvent.mockClear(); const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; globalThis[rLynxChange[0]](rLynxChange[1]); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); rLynxChange[2](); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` - [ - [ - [ - "rLynxRef", - { - "commitTaskId": 3, - "refPatch": "{"-2:0:ref":46,"-2:1:ref":47}", - }, - ], - ], - ] - `); + lynx.getNativeApp().callLepusMethod.mockClear(); } // ref { globalEnvManager.switchToBackground(); - await waitSchedule(); expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - { - "selectUniqueID": [Function], - "uid": 46, + RefProxy { + "refAttr": [ + -2, + 0, + ], + "task": undefined, }, ], ] `); expect(ref2).toMatchInlineSnapshot(` { - "current": { - "selectUniqueID": [Function], - "uid": 47, + "current": RefProxy { + "refAttr": [ + -2, + 1, + ], + "task": undefined, }, } `); @@ -1320,14 +1174,50 @@ describe('element ref in spread', () => { `); expect(oldRef2.current).toBeNull(); expect(oldRef3.current).toBeNull(); + ref1.mockClear(); + } + + // update ref + { + ref3 = createRef(); + spread3 = { ref: ref3 }; + render(, __root); + expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); + expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( + `"{"patchList":[{"id":4,"snapshotPatch":[3,-2,2,{"ref":1}]}]}"`, + ); + expect(ref1.mock.calls).toMatchInlineSnapshot(` + [ + [ + null, + ], + [ + RefProxy { + "refAttr": [ + -2, + 0, + ], + "task": undefined, + }, + ], + ] + `); + expect(ref3.current).toMatchInlineSnapshot(` + RefProxy { + "refAttr": [ + -2, + 2, + ], + "task": undefined, + } + `); } }); }); describe('element ref in list', () => { - it('hydrate', async function() { + it('should trigger ref in list', async function() { const refs = [createRef(), createRef(), createRef()]; - const signs = [0, 0, 0]; class ListItem extends Component { render() { @@ -1367,17 +1257,17 @@ describe('element ref in list', () => { { "item-key": 0, "position": 0, - "type": "__Card__:__snapshot_a94a8_test_19", + "type": "__Card__:__snapshot_a94a8_test_21", }, { "item-key": 1, "position": 1, - "type": "__Card__:__snapshot_a94a8_test_19", + "type": "__Card__:__snapshot_a94a8_test_21", }, { "item-key": 2, "position": 2, - "type": "__Card__:__snapshot_a94a8_test_19", + "type": "__Card__:__snapshot_a94a8_test_21", }, ], "removeAction": [], @@ -1395,140 +1285,59 @@ describe('element ref in list', () => { globalEnvManager.switchToBackground(); render(, __root); lynx.getNativeApp().callLepusMethod.mockClear(); - } - - // hydrate - { - // LifecycleConstant.firstScreen - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - globalThis.__OnLifecycleEvent.mockClear(); - // rLynxChange - globalEnvManager.switchToMainThread(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`[]`); - } - - // list render item 1 & 2 - { - signs[0] = elementTree.triggerComponentAtIndex(__root.childNodes[0].__elements[0], 0); - signs[1] = elementTree.triggerComponentAtIndex(__root.childNodes[0].__elements[0], 1); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` - [ - [ - [ - "rLynxRef", - { - "commitTaskId": undefined, - "refPatch": "{"-4:0:":52}", - }, - ], - ], - [ - [ - "rLynxRef", - { - "commitTaskId": undefined, - "refPatch": "{"-6:0:":54}", - }, - ], - ], - ] - `); - globalEnvManager.switchToBackground(); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[1]); - globalThis.__OnLifecycleEvent.mockClear(); expect(refs[0]).toMatchInlineSnapshot(` { - "current": { - "selectUniqueID": [Function], - "uid": 52, + "current": RefProxy { + "refAttr": [ + 4, + 0, + ], + "task": undefined, }, } `); expect(refs[1]).toMatchInlineSnapshot(` { - "current": { - "selectUniqueID": [Function], - "uid": 54, - }, - } - `); - expect(refs[2].current).toBeNull(); - } - - // list enqueue item 1 & render item 3 - { - globalEnvManager.switchToMainThread(); - elementTree.triggerEnqueueComponent(__root.childNodes[0].__elements[0], signs[0]); - elementTree.triggerComponentAtIndex(__root.childNodes[0].__elements[0], 2); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` - [ - [ - [ - "rLynxRef", - { - "commitTaskId": undefined, - "refPatch": "{"-4:0:":null,"-8:0:":52}", - }, + "current": RefProxy { + "refAttr": [ + 6, + 0, ], - ], - ] - `); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - globalThis.__OnLifecycleEvent.mockClear(); - expect(refs[0].current).toBeNull(); - expect(refs[1]).toMatchInlineSnapshot(` - { - "current": { - "selectUniqueID": [Function], - "uid": 54, + "task": undefined, }, } `); expect(refs[2]).toMatchInlineSnapshot(` { - "current": { - "selectUniqueID": [Function], - "uid": 52, + "current": RefProxy { + "refAttr": [ + 8, + 0, + ], + "task": undefined, }, } `); } - - // list enqueue item 2 & render item 2 - { - globalEnvManager.switchToMainThread(); - elementTree.triggerEnqueueComponent(__root.childNodes[0].__elements[0], signs[1]); - elementTree.triggerComponentAtIndex(__root.childNodes[0].__elements[0], 1); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`[]`); - } }); +}); - it('continuously reuse', async function() { - const refs = [createRef(), createRef(), createRef()]; - const signs = [0, 0, 0]; - - class ListItem extends Component { - render() { - return ; - } - } +describe('ui operations', () => { + it('should delay until hydration finished', async function() { + const ref1 = vi.fn((ref) => { + ref.invoke({ + method: 'boundingClientRect', + }).exec(); + }); class Comp extends Component { + x = 'x'; render() { return ( - - {[0, 1, 2].map((index) => { - return ( - - - - ); - })} - + + + ); } } @@ -1543,141 +1352,105 @@ describe('element ref in list', () => { { globalEnvManager.switchToBackground(); render(, __root); - lynx.getNativeApp().callLepusMethod.mockClear(); + expect(lynx.createSelectorQuery().constructor.execLog.mock.calls).toMatchInlineSnapshot(`[]`); } // hydrate { - // LifecycleConstant.firstScreen lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); globalThis.__OnLifecycleEvent.mockClear(); - - // rLynxChange - globalEnvManager.switchToMainThread(); - const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxChange[0]](rLynxChange[1]); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`[]`); - } - - // list render item 1 - { - signs[0] = elementTree.triggerComponentAtIndex(__root.childNodes[0].__elements[0], 0); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` + expect(lynx.createSelectorQuery().constructor.execLog.mock.calls).toMatchInlineSnapshot(` [ [ + "[react-ref--2-0]", + "invoke", [ - "rLynxRef", { - "commitTaskId": undefined, - "refPatch": "{"-4:0:":58}", + "method": "boundingClientRect", }, ], ], ] `); + lynx.createSelectorQuery().constructor.execLog.mockClear(); + } + }); + + it('should support more usages of ref 1', async function() { + const ref1 = vi.fn((ref) => { + ref.setNativeProps({ + 'background-color': 'blue', + }).exec(); + ref.path(vi.fn()).exec(); + }); + + class Comp extends Component { + x = 'x'; + render() { + return ( + + + + ); + } + } + + // main thread render + { + __root.__jsx = ; + renderPage(); + } + + // background render + { globalEnvManager.switchToBackground(); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - globalThis.__OnLifecycleEvent.mockClear(); - expect(refs[0]).toMatchInlineSnapshot(` - { - "current": { - "selectUniqueID": [Function], - "uid": 58, - }, - } - `); - expect(refs[1].current).toBeNull(); - expect(refs[2].current).toBeNull(); + render(, __root); } - // list enqueue item 1 & render item 2 + // hydrate { - globalEnvManager.switchToMainThread(); - elementTree.triggerEnqueueComponent(__root.childNodes[0].__elements[0], signs[0]); - signs[1] = elementTree.triggerComponentAtIndex(__root.childNodes[0].__elements[0], 1); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` + lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + globalThis.__OnLifecycleEvent.mockClear(); + expect(lynx.createSelectorQuery().constructor.execLog.mock.calls).toMatchInlineSnapshot(` [ [ + "[react-ref--2-0]", + "setNativeProps", [ - "rLynxRef", { - "commitTaskId": undefined, - "refPatch": "{"-4:0:":null,"-6:0:":58}", + "background-color": "blue", }, ], ], - ] - `); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - globalThis.__OnLifecycleEvent.mockClear(); - expect(refs[0].current).toBeNull(); - expect(refs[1]).toMatchInlineSnapshot(` - { - "current": { - "selectUniqueID": [Function], - "uid": 58, - }, - } - `); - expect(refs[2].current).toBeNull(); - } - - // list enqueue item 2 & render item 3 - { - globalEnvManager.switchToMainThread(); - elementTree.triggerEnqueueComponent(__root.childNodes[0].__elements[0], signs[1]); - signs[2] = elementTree.triggerComponentAtIndex(__root.childNodes[0].__elements[0], 2); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` - [ [ + "[react-ref--2-0]", + "path", [ - "rLynxRef", - { - "commitTaskId": undefined, - "refPatch": "{"-6:0:":null,"-8:0:":58}", - }, + [MockFunction spy], ], ], ] `); - lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); - globalThis.__OnLifecycleEvent.mockClear(); - expect(refs[0].current).toBeNull(); - expect(refs[1].current).toBeNull(); - expect(refs[2]).toMatchInlineSnapshot(` - { - "current": { - "selectUniqueID": [Function], - "uid": 58, - }, - } - `); + lynx.createSelectorQuery().constructor.execLog.mockClear(); } }); - it('when __FIRST_SCREEN_SYNC_TIMING__ is jsReady', async function() { - globalThis.__FIRST_SCREEN_SYNC_TIMING__ = 'jsReady'; - const refs = [createRef(), createRef(), createRef()]; - const signs = [0, 0, 0]; - - class ListItem extends Component { - render() { - return ; - } - } + it('should support more usages of ref 2', async function() { + const ref1 = vi.fn((ref) => { + const fields = ref.fields({ + id: true, + }); + fields.exec(); + fields.exec(); + }); class Comp extends Component { + x = 'x'; render() { return ( - - {[0, 1, 2].map((index) => { - return ( - - - - ); - })} - + + + ); } } @@ -1688,71 +1461,159 @@ describe('element ref in list', () => { renderPage(); } - // list render item 1 & 2 + // background render { - signs[0] = elementTree.triggerComponentAtIndex(__root.childNodes[0].__elements[0], 0); - expect(globalThis.__OnLifecycleEvent).toHaveBeenCalledTimes(1); - globalEnvManager.switchToBackground(); + render(, __root); + } + + // hydrate + { lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); globalThis.__OnLifecycleEvent.mockClear(); - expect(delayedLifecycleEvents).toMatchInlineSnapshot(` + expect(lynx.createSelectorQuery().constructor.execLog.mock.calls).toMatchInlineSnapshot(` [ [ - "rLynxRef", - { - "commitTaskId": undefined, - "refPatch": "{"-4:0:":62}", - }, + "[react-ref--2-0]", + "fields", + [ + { + "id": true, + }, + ], + ], + [ + "[react-ref--2-0]", + "fields", + [ + { + "id": true, + }, + ], ], ] `); + lynx.createSelectorQuery().constructor.execLog.mockClear(); + } + }); + + it('should not delay after hydration', async function() { + const ref1 = createRef(); + + function Comp() { + return ( + + + + ); + } + + // main thread render + { + __root.__jsx = ; + renderPage(); } // background render { globalEnvManager.switchToBackground(); - root.render(, __root); - expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1); - expect(lynx.getNativeApp().callLepusMethod.mock.calls[0]).toMatchInlineSnapshot(` - [ - "rLynxJSReady", - {}, - ] - `); - globalEnvManager.switchToMainThread(); - const rLynxJSReady = lynx.getNativeApp().callLepusMethod.mock.calls[0]; - globalThis[rLynxJSReady[0]](rLynxJSReady[1]); + render(, __root); + } + + // hydrate + { + lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); + globalThis.__OnLifecycleEvent.mockClear(); + expect(lynx.createSelectorQuery().constructor.execLog.mock.calls).toMatchInlineSnapshot(`[]`); + + lynx.createSelectorQuery().constructor.execLog.mockClear(); lynx.getNativeApp().callLepusMethod.mockClear(); - expect(globalThis.__OnLifecycleEvent).toHaveBeenCalledTimes(1); - expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(` + } + + // call ref + { + ref1.current.invoke({ + method: 'boundingClientRect', + }).exec(); + expect(lynx.createSelectorQuery().constructor.execLog.mock.calls).toMatchInlineSnapshot(` [ [ + "[react-ref--2-0]", + "invoke", [ - "rLynxFirstScreen", { - "jsReadyEventIdSwap": {}, - "refPatch": "{}", - "root": "{"id":-1,"type":"root","children":[{"id":-2,"type":"__Card__:__snapshot_a94a8_test_24","children":[{"id":-3,"type":"__Card__:__snapshot_a94a8_test_25","values":[{"item-key":0}],"children":[{"id":-4,"type":"__Card__:__snapshot_a94a8_test_23","values":["-4:0:"]}]},{"id":-5,"type":"__Card__:__snapshot_a94a8_test_25","values":[{"item-key":1}],"children":[{"id":-6,"type":"__Card__:__snapshot_a94a8_test_23","values":["-6:0:"]}]},{"id":-7,"type":"__Card__:__snapshot_a94a8_test_25","values":[{"item-key":2}],"children":[{"id":-8,"type":"__Card__:__snapshot_a94a8_test_23","values":["-8:0:"]}]}]}]}", + "method": "boundingClientRect", }, ], ], ] `); } + }); + + it('should not delay after hydration', async function() { + const ref1 = vi.fn((ref) => { + ref.invoke({ + method: 'boundingClientRect', + }).exec(); + }); + let show = false; + function Child() { + return ; + } + + function Comp() { + return ( + + {show ? : null} + + ); + } + + // main thread render + { + __root.__jsx = ; + renderPage(); + } + + // background render { globalEnvManager.switchToBackground(); + render(, __root); + } + + // hydrate + { lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]); globalThis.__OnLifecycleEvent.mockClear(); + expect(lynx.createSelectorQuery().constructor.execLog.mock.calls).toMatchInlineSnapshot(`[]`); - expect(refs[0].current).toMatchInlineSnapshot(` - { - "selectUniqueID": [Function], - "uid": 62, - } + lynx.createSelectorQuery().constructor.execLog.mockClear(); + lynx.getNativeApp().callLepusMethod.mockClear(); + } + + // set show + { + show = true; + render(, __root); + + expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot( + `"{"patchList":[{"id":3,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_26",3,4,3,[1],1,-2,3,null]}]}"`, + ); + expect(lynx.createSelectorQuery().constructor.execLog.mock.calls).toMatchInlineSnapshot(` + [ + [ + "[react-ref-3-0]", + "invoke", + [ + { + "method": "boundingClientRect", + }, + ], + ], + ] `); } - globalThis.__FIRST_SCREEN_SYNC_TIMING__ = 'immediately'; }); }); diff --git a/packages/react/runtime/__test__/ssr.test.jsx b/packages/react/runtime/__test__/ssr.test.jsx index 589ba928c7..3a3920d0d0 100644 --- a/packages/react/runtime/__test__/ssr.test.jsx +++ b/packages/react/runtime/__test__/ssr.test.jsx @@ -1,12 +1,13 @@ /** @jsxImportSource ../lepus */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { elementTree, options } from './utils/nativeMethod'; + import { globalEnvManager } from './utils/envManager'; -import { __root } from '../src/root'; +import { elementTree, options } from './utils/nativeMethod'; import { __page as __internalPage } from '../src/internal'; -import { clearPage } from '../src/snapshot'; import { jsReadyEventIdSwap } from '../src/lifecycle/event/jsReady'; +import { __root } from '../src/root'; +import { clearPage } from '../src/snapshot'; const ssrIDMap = new Map(); @@ -211,7 +212,7 @@ describe('ssr', () => { "color": "red", }, "-2:2:", - "-2:3:", + "react-ref--2-3", { "_lepusWorkletHash": "1", "_workletType": "main-thread", @@ -273,7 +274,7 @@ describe('ssr', () => { "main-thread:ref": { "_lepusWorkletHash": "2", }, - "ref": "-2:0:ref", + "ref": "react-ref--2-0", "style": { "color": "red", }, diff --git a/packages/react/runtime/__test__/utils/envManager.ts b/packages/react/runtime/__test__/utils/envManager.ts index 21e6d6b261..bb73467090 100644 --- a/packages/react/runtime/__test__/utils/envManager.ts +++ b/packages/react/runtime/__test__/utils/envManager.ts @@ -3,13 +3,15 @@ // 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 { setupBackgroundDocument, setupDocument } from '../../src/document.js'; -import { __root, setRoot } from '../../src/root.js'; import { BackgroundSnapshotInstance } from '../../src/backgroundSnapshot.js'; -import { backgroundSnapshotInstanceManager, SnapshotInstance, snapshotInstanceManager } from '../../src/snapshot.js'; +import { setupBackgroundDocument, setupDocument } from '../../src/document.js'; import { deinitGlobalSnapshotPatch } from '../../src/lifecycle/patch/snapshotPatch.js'; -import { globalPipelineOptions, setPipeline } from '../../src/lynx/performance.js'; +import { shouldDelayUiOps } from '../../src/lifecycle/ref/delay.js'; import { clearListGlobal } from '../../src/list.js'; +import { globalPipelineOptions, setPipeline } from '../../src/lynx/performance.js'; +import { __root, setRoot } from '../../src/root.js'; +import { SnapshotInstance, backgroundSnapshotInstanceManager, snapshotInstanceManager } from '../../src/snapshot.js'; +import { hydrationMap } from '../../src/snapshotInstanceHydrationMap.js'; export class EnvManager { root: typeof __root | undefined; @@ -70,6 +72,8 @@ export class EnvManager { backgroundSnapshotInstanceManager.nextId = 0; snapshotInstanceManager.clear(); snapshotInstanceManager.nextId = 0; + hydrationMap.clear(); + shouldDelayUiOps.value = true; clearListGlobal(); deinitGlobalSnapshotPatch(); this.switchToBackground(); diff --git a/packages/react/runtime/__test__/utils/globals.js b/packages/react/runtime/__test__/utils/globals.js index d2928a9df7..c6604cbb3f 100644 --- a/packages/react/runtime/__test__/utils/globals.js +++ b/packages/react/runtime/__test__/utils/globals.js @@ -35,6 +35,46 @@ const performance = { }), }; +class SelectorQuery { + static execLog = vi.fn(); + id = ''; + method = ''; + params = undefined; + + select(id) { + this.id = id; + return this; + } + + invoke(...args) { + this.method = 'invoke'; + this.params = args; + return this; + } + + path(...args) { + this.method = 'path'; + this.params = args; + return this; + } + + fields(...args) { + this.method = 'fields'; + this.params = args; + return this; + } + + setNativeProps(...args) { + this.method = 'setNativeProps'; + this.params = args; + return this; + } + + exec() { + SelectorQuery.execLog(this.id, this.method, this.params); + } +} + function injectGlobals() { globalThis.__DEV__ = true; globalThis.__PROFILE__ = true; @@ -52,11 +92,28 @@ function injectGlobals() { globalThis.lynx = { getNativeApp: () => app, performance, - createSelectorQuery: vi.fn(() => { + createSelectorQuery: () => { + return new SelectorQuery(); + }, + getElementByIdTasks: vi.fn(), + getElementById: vi.fn((id) => { return { - selectUniqueID: function(uid) { - this.uid = uid; - return this; + animate: vi.fn(() => { + lynx.getElementByIdTasks('animate'); + return { + play: () => { + lynx.getElementByIdTasks('play'); + }, + pause: () => { + lynx.getElementByIdTasks('pause'); + }, + cancel: () => { + lynx.getElementByIdTasks('cancel'); + }, + }; + }), + setProperty: (property, value) => { + lynx.getElementByIdTasks('setProperty', property, value); }, }; }), diff --git a/packages/react/runtime/lazy/internal.js b/packages/react/runtime/lazy/internal.js index f26c184d94..bac01f2d06 100644 --- a/packages/react/runtime/lazy/internal.js +++ b/packages/react/runtime/lazy/internal.js @@ -16,6 +16,7 @@ export const { __page, __pageId, __root, + applyRefs, createSnapshot, loadDynamicJS, loadLazyBundle, diff --git a/packages/react/runtime/src/backgroundSnapshot.ts b/packages/react/runtime/src/backgroundSnapshot.ts index cc27f82b48..02a1f8452f 100644 --- a/packages/react/runtime/src/backgroundSnapshot.ts +++ b/packages/react/runtime/src/backgroundSnapshot.ts @@ -22,6 +22,7 @@ import { takeGlobalSnapshotPatch, } from './lifecycle/patch/snapshotPatch.js'; import { globalPipelineOptions } from './lynx/performance.js'; +import { transformSpread } from './snapshot/spread.js'; import type { SerializedSnapshotInstance } from './snapshot.js'; import { DynamicPartType, @@ -29,8 +30,7 @@ import { snapshotManager, traverseSnapshotInstance, } from './snapshot.js'; -import { markRefToRemove } from './snapshot/ref.js'; -import { transformSpread } from './snapshot/spread.js'; +import { hydrationMap } from './snapshotInstanceHydrationMap.js'; import { isDirectOrDeepEqual } from './utils.js'; import { onPostWorkletCtx } from './worklet/ctx.js'; @@ -188,7 +188,7 @@ export class BackgroundSnapshotInstance { for (let index = 0; index < value.length; index++) { const { needUpdate, valueToCommit } = this.setAttributeImpl(value[index], oldValues[index], index); if (needUpdate) { - __globalSnapshotPatch?.push( + __globalSnapshotPatch!.push( SnapshotOperation.SetAttribute, this.__id, index, @@ -203,7 +203,7 @@ export class BackgroundSnapshotInstance { const { valueToCommit } = this.setAttributeImpl(value[index], null, index); patch[index] = valueToCommit; } - __globalSnapshotPatch?.push( + __globalSnapshotPatch!.push( SnapshotOperation.SetAttributes, this.__id, patch, @@ -238,9 +238,6 @@ export class BackgroundSnapshotInstance { valueToCommit: any; } { if (!newValue) { - if (oldValue && oldValue.__ref) { - markRefToRemove(`${this.__id}:${index}:`, oldValue); - } return { needUpdate: oldValue !== newValue, valueToCommit: newValue }; } @@ -253,10 +250,7 @@ export class BackgroundSnapshotInstance { // use __spread to cache the transform result for next diff newValue.__spread = newSpread; if (needUpdate) { - if (oldSpread && oldSpread.ref) { - markRefToRemove(`${this.__id}:${index}:ref`, oldValue.ref); - } - for (let key in newSpread) { + for (const key in newSpread) { const newSpreadValue = newSpread[key]; if (!newSpreadValue) { continue; @@ -265,22 +259,15 @@ export class BackgroundSnapshotInstance { newSpread[key] = onPostWorkletCtx(newSpreadValue as Worklet); } else if ((newSpreadValue as any).__isGesture) { processGestureBackground(newSpreadValue as GestureKind); - } else if (key == '__lynx_timing_flag' && oldSpread?.[key] != newSpreadValue) { - if (globalPipelineOptions) { - globalPipelineOptions.needTimestamps = true; - } + } else if (key == '__lynx_timing_flag' && oldSpread?.[key] != newSpreadValue && globalPipelineOptions) { + globalPipelineOptions.needTimestamps = true; } } } return { needUpdate, valueToCommit: newSpread }; } if (newValue.__ref) { - // force update to update ref value - // TODO: ref: optimize this. The ref update maybe can be done on the background thread to reduce updating. - // The old ref must have a place to be stored because it needs to be cleared when the main thread returns. - markRefToRemove(`${this.__id}:${index}:`, oldValue); - // update ref. On the main thread, the ref id will be replaced with value's sign when updating. - return { needUpdate: true, valueToCommit: newValue.__ref }; + return { needUpdate: false, valueToCommit: 1 }; } if (newValue._wkltId) { return { needUpdate: true, valueToCommit: onPostWorkletCtx(newValue) }; @@ -301,8 +288,7 @@ export class BackgroundSnapshotInstance { } if (newType === 'function') { if (newValue.__ref) { - markRefToRemove(`${this.__id}:${index}:`, oldValue); - return { needUpdate: true, valueToCommit: newValue.__ref }; + return { needUpdate: false, valueToCommit: 1 }; } /* event */ return { needUpdate: !oldValue, valueToCommit: 1 }; @@ -335,6 +321,7 @@ export function hydrate( before: SerializedSnapshotInstance, after: BackgroundSnapshotInstance, ) => { + hydrationMap.set(after.__id, before.id); backgroundSnapshotInstanceManager.updateId(after.__id, before.id); after.__values?.forEach((value, index) => { const old = before.values![index]; @@ -344,7 +331,7 @@ export function hydrate( // `value.__spread` my contain event ids using snapshot ids before hydration. Remove it. delete value.__spread; value = transformSpread(after, index, value); - for (let key in value) { + for (const key in value) { if (value[key] && value[key]._wkltId) { onPostWorkletCtx(value[key]); } else if (value[key] && value[key].__isGesture) { @@ -353,12 +340,8 @@ export function hydrate( } after.__values![index]!.__spread = value; } else if (value.__ref) { - if (old) { - // skip patch - value = old; - } else { - value = value.__ref; - } + // skip patch + value = old; } else if (typeof value === 'function') { value = `${after.__id}:${index}:`; } diff --git a/packages/react/runtime/src/hooks/react.ts b/packages/react/runtime/src/hooks/react.ts index c582e96e40..95d4665884 100644 --- a/packages/react/runtime/src/hooks/react.ts +++ b/packages/react/runtime/src/hooks/react.ts @@ -9,7 +9,7 @@ import { useId, useImperativeHandle, useMemo, - useLayoutEffect as usePreactLayoutEffect, + useEffect as usePreactEffect, useReducer, useRef, useState, @@ -29,7 +29,7 @@ import type { DependencyList, EffectCallback } from 'react'; * @deprecated `useLayoutEffect` in the background thread cannot offer the precise timing for reading layout information and synchronously re-render, which is different from React. */ function useLayoutEffect(effect: EffectCallback, deps?: DependencyList): void { - return usePreactLayoutEffect(effect, deps); + return usePreactEffect(effect, deps); } /** @@ -42,7 +42,7 @@ function useLayoutEffect(effect: EffectCallback, deps?: DependencyList): void { * @public */ function useEffect(effect: EffectCallback, deps?: DependencyList): void { - return usePreactLayoutEffect(effect, deps); + return usePreactEffect(effect, deps); } export { diff --git a/packages/react/runtime/src/internal.ts b/packages/react/runtime/src/internal.ts index 40c8063496..d66f23754b 100644 --- a/packages/react/runtime/src/internal.ts +++ b/packages/react/runtime/src/internal.ts @@ -26,7 +26,7 @@ export const __DynamicPartChildren_0: [DynamicPartType, number][] = [[DynamicPar export { updateSpread } from './snapshot/spread.js'; export { updateEvent } from './snapshot/event.js'; -export { updateRef, transformRef } from './snapshot/ref.js'; +export { updateRef, transformRef, applyRefs } from './snapshot/ref.js'; export { updateWorkletEvent } from './snapshot/workletEvent.js'; export { updateWorkletRef } from './snapshot/workletRef.js'; export { updateGesture } from './snapshot/gesture.js'; diff --git a/packages/react/runtime/src/lifecycle/event/delayLifecycleEvents.ts b/packages/react/runtime/src/lifecycle/event/delayLifecycleEvents.ts index 1b0c61a6da..4df1b7bb80 100644 --- a/packages/react/runtime/src/lifecycle/event/delayLifecycleEvents.ts +++ b/packages/react/runtime/src/lifecycle/event/delayLifecycleEvents.ts @@ -1,18 +1,7 @@ -import { LifecycleConstant } from '../../lifecycleConstant.js'; - const delayedLifecycleEvents: [type: string, data: any][] = []; function delayLifecycleEvent(type: string, data: any): void { - // We need to ensure that firstScreen events are executed before other events. - // This is because firstScreen events are used to initialize the dom tree, - // and other events depend on the dom tree being fully constructed. - // There might be some edge cases where ctx cannot be found in `ref` lifecycle event, - // and they should be ignored safely. - if (type === LifecycleConstant.firstScreen) { - delayedLifecycleEvents.unshift([type, data]); - } else { - delayedLifecycleEvents.push([type, data]); - } + delayedLifecycleEvents.push([type, data]); } /** diff --git a/packages/react/runtime/src/lifecycle/event/jsReady.ts b/packages/react/runtime/src/lifecycle/event/jsReady.ts index eaa1d23765..93863d5e91 100644 --- a/packages/react/runtime/src/lifecycle/event/jsReady.ts +++ b/packages/react/runtime/src/lifecycle/event/jsReady.ts @@ -1,6 +1,5 @@ import { LifecycleConstant } from '../../lifecycleConstant.js'; import { __root } from '../../root.js'; -import { takeGlobalRefPatchMap } from '../../snapshot/ref.js'; let isJSReady: boolean; let jsReadyEventIdSwap: Record; @@ -11,7 +10,6 @@ function jsReady(): void { LifecycleConstant.firstScreen, /* FIRST_SCREEN */ { root: JSON.stringify(__root), - refPatch: JSON.stringify(takeGlobalRefPatchMap()), jsReadyEventIdSwap, }, ]); diff --git a/packages/react/runtime/src/lifecycle/patch/commit.ts b/packages/react/runtime/src/lifecycle/patch/commit.ts index ad3937e3f2..57d1b4cee2 100644 --- a/packages/react/runtime/src/lifecycle/patch/commit.ts +++ b/packages/react/runtime/src/lifecycle/patch/commit.ts @@ -32,8 +32,8 @@ import { setPipeline, } from '../../lynx/performance.js'; import { CATCH_ERROR, COMMIT, RENDER_CALLBACKS, VNODE } from '../../renderToOpcodes/constants.js'; +import { applyDelayedRefs } from '../../snapshot/ref.js'; import { backgroundSnapshotInstanceManager } from '../../snapshot.js'; -import { updateBackgroundRefs } from '../../snapshot/ref.js'; import { isEmptyObject } from '../../utils.js'; import { takeWorkletRefInitValuePatch } from '../../worklet/workletRefPool.js'; import { runDelayedUnmounts, takeDelayedUnmounts } from '../delayUnmount.js'; @@ -43,7 +43,7 @@ import { takeGlobalSnapshotPatch } from './snapshotPatch.js'; let globalFlushOptions: FlushOptions = {}; -const globalCommitTaskMap: Map void> = /*@__PURE__*/ new Map(); +const globalCommitTaskMap: Map void> = /*@__PURE__*/ new Map void>(); let nextCommitTaskId = 1; let globalBackgroundSnapshotInstancesToRemove: number[] = []; @@ -52,6 +52,7 @@ let globalBackgroundSnapshotInstancesToRemove: number[] = []; * A single patch operation. */ interface Patch { + // TODO: ref: do we need `id`? id: number; snapshotPatch?: SnapshotPatch; workletRefInitValuePatch?: [id: number, value: unknown][]; @@ -112,7 +113,6 @@ function replaceCommitHook(): void { // Register the commit task globalCommitTaskMap.set(commitTaskId, () => { - updateBackgroundRefs(commitTaskId); runDelayedUnmounts(delayedUnmounts); originalPreactCommit?.(vnode, renderCallbacks); renderCallbacks.some(wrapper => { @@ -140,6 +140,7 @@ function replaceCommitHook(): void { globalFlushOptions = {}; if (!snapshotPatch && workletRefInitValuePatch.length === 0) { // before hydration, skip patch + applyDelayedRefs(); return; } @@ -169,6 +170,8 @@ function replaceCommitHook(): void { globalCommitTaskMap.delete(commitTaskId); } }); + + applyDelayedRefs(); }; options[COMMIT] = commit as ((...args: Parameters) => void); } @@ -243,7 +246,6 @@ export { globalBackgroundSnapshotInstancesToRemove, globalCommitTaskMap, globalFlushOptions, - nextCommitTaskId, replaceCommitHook, replaceRequestAnimationFrame, type PatchList, diff --git a/packages/react/runtime/src/lifecycle/patch/updateMainThread.ts b/packages/react/runtime/src/lifecycle/patch/updateMainThread.ts index da2001ba59..bf261a9884 100644 --- a/packages/react/runtime/src/lifecycle/patch/updateMainThread.ts +++ b/packages/react/runtime/src/lifecycle/patch/updateMainThread.ts @@ -4,15 +4,13 @@ import { clearDelayedWorklets, updateWorkletRefInitValueChanges } from '@lynx-js/react/worklet-runtime/bindings'; -import type { PatchList, PatchOptions } from './commit.js'; -import { snapshotPatchApply } from './snapshotPatchApply.js'; import { LifecycleConstant } from '../../lifecycleConstant.js'; import { __pendingListUpdates } from '../../list.js'; import { PerformanceTimingKeys, markTiming, setPipeline } from '../../lynx/performance.js'; -import { takeGlobalRefPatchMap } from '../../snapshot/ref.js'; import { __page } from '../../snapshot.js'; -import { isEmptyObject } from '../../utils.js'; import { getReloadVersion } from '../pass.js'; +import type { PatchList, PatchOptions } from './commit.js'; +import { snapshotPatchApply } from './snapshotPatchApply.js'; function updateMainThread( { data, patchOptions }: { @@ -32,7 +30,7 @@ function updateMainThread( markTiming(PerformanceTimingKeys.parseChangesEnd); markTiming(PerformanceTimingKeys.patchChangesStart); - for (const { snapshotPatch, workletRefInitValuePatch, id } of patchList) { + for (const { snapshotPatch, workletRefInitValuePatch } of patchList) { updateWorkletRefInitValueChanges(workletRefInitValuePatch); __pendingListUpdates.clear(); if (snapshotPatch) { @@ -41,8 +39,6 @@ function updateMainThread( __pendingListUpdates.flush(); // console.debug('********** Lepus updatePatch:'); // printSnapshotInstance(snapshotInstanceManager.values.get(-1)!); - - commitMainThreadPatchUpdate(id); } markTiming(PerformanceTimingKeys.patchChangesEnd); markTiming(PerformanceTimingKeys.mtsRenderEnd); @@ -60,14 +56,7 @@ function injectUpdateMainThread(): void { Object.assign(globalThis, { [LifecycleConstant.patchUpdate]: updateMainThread }); } -function commitMainThreadPatchUpdate(commitTaskId?: number): void { - const refPatch = takeGlobalRefPatchMap(); - if (!isEmptyObject(refPatch)) { - __OnLifecycleEvent([LifecycleConstant.ref, { commitTaskId, refPatch: JSON.stringify(refPatch) }]); - } -} - /** * @internal */ -export { commitMainThreadPatchUpdate, injectUpdateMainThread }; +export { injectUpdateMainThread }; diff --git a/packages/react/runtime/src/lifecycle/ref/delay.ts b/packages/react/runtime/src/lifecycle/ref/delay.ts new file mode 100644 index 0000000000..8cea860c47 --- /dev/null +++ b/packages/react/runtime/src/lifecycle/ref/delay.ts @@ -0,0 +1,99 @@ +// 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 type { NodesRef, SelectorQuery } from '@lynx-js/types'; + +import { hydrationMap } from '../../snapshotInstanceHydrationMap.js'; + +type RefTask = (nodesRef: NodesRef) => SelectorQuery; + +/** + * A flag to indicate whether UI operations should be delayed. + * When set to true, UI operations will be queued in the `delayedUiOps` array + * and executed later when `runDelayedUiOps` is called. + * This is used before hydration to ensure UI operations are batched + * and executed at the appropriate time. + */ +const shouldDelayUiOps = { value: true }; + +/** + * An array of functions that will be executed later when `runDelayedUiOps` is called. + * These functions contain UI operations that need to be delayed. + */ +const delayedUiOps: (() => void)[] = []; + +/** + * Runs a task either immediately or delays it based on the `shouldDelayUiOps` flag. + * @param task - The function to execute. + */ +function runOrDelay(task: () => void): void { + if (shouldDelayUiOps.value) { + delayedUiOps.push(task); + } else { + task(); + } +} + +/** + * Executes all delayed UI operations. + */ +function runDelayedUiOps(): void { + for (const task of delayedUiOps) { + task(); + } + shouldDelayUiOps.value = false; + delayedUiOps.length = 0; +} + +/** + * A proxy class designed for managing and executing reference-based tasks. + * It delays the execution of tasks until hydration is complete. + */ +class RefProxy { + private readonly refAttr: [snapshotInstanceId: number, expIndex: number]; + private task: RefTask | undefined; + + constructor(refAttr: [snapshotInstanceId: number, expIndex: number]) { + this.refAttr = refAttr; + } + + private setTask( + method: K, + args: Parameters, + ): this { + this.task = (nodesRef) => { + return (nodesRef[method] as unknown as (...args: any[]) => SelectorQuery)(...args); + }; + return this; + } + + invoke(...args: Parameters): RefProxy { + return new RefProxy(this.refAttr).setTask('invoke', args); + } + + path(...args: Parameters): RefProxy { + return new RefProxy(this.refAttr).setTask('path', args); + } + + fields(...args: Parameters): RefProxy { + return new RefProxy(this.refAttr).setTask('fields', args); + } + + setNativeProps(...args: Parameters): RefProxy { + return new RefProxy(this.refAttr).setTask('setNativeProps', args); + } + + exec(): void { + runOrDelay(() => { + const realRefId = hydrationMap.get(this.refAttr[0]) ?? this.refAttr[0]; + const refSelector = `[react-ref-${realRefId}-${this.refAttr[1]}]`; + this.task!(lynx.createSelectorQuery().select(refSelector)).exec(); + }); + } +} + +/** + * @internal + */ +export { RefProxy, runDelayedUiOps, shouldDelayUiOps }; diff --git a/packages/react/runtime/src/lifecycle/reload.ts b/packages/react/runtime/src/lifecycle/reload.ts index 7ffd950e0b..32bf5cf29d 100644 --- a/packages/react/runtime/src/lifecycle/reload.ts +++ b/packages/react/runtime/src/lifecycle/reload.ts @@ -14,13 +14,13 @@ import { LifecycleConstant } from '../lifecycleConstant.js'; import { __pendingListUpdates } from '../list.js'; import { __root, setRoot } from '../root.js'; import { SnapshotInstance, __page, snapshotInstanceManager } from '../snapshot.js'; -import { takeGlobalRefPatchMap } from '../snapshot/ref.js'; import { isEmptyObject } from '../utils.js'; -import { destroyWorklet } from '../worklet/destroy.js'; import { destroyBackground } from './destroy.js'; +import { destroyWorklet } from '../worklet/destroy.js'; import { clearJSReadyEventIdSwap, isJSReady } from './event/jsReady.js'; import { increaseReloadVersion } from './pass.js'; import { deinitGlobalSnapshotPatch } from './patch/snapshotPatch.js'; +import { shouldDelayUiOps } from './ref/delay.js'; import { renderMainThread } from './render.js'; function reloadMainThread(data: any, options: UpdatePageOption): void { @@ -55,7 +55,6 @@ function reloadMainThread(data: any, options: UpdatePageOption): void { LifecycleConstant.firstScreen, /* FIRST_SCREEN */ { root: JSON.stringify(__root), - refPatch: JSON.stringify(takeGlobalRefPatchMap()), }, ]); } @@ -82,6 +81,7 @@ function reloadBackground(updateData: Record): void { // COW when modify `lynx.__initData` to make sure Provider & Consumer works lynx.__initData = Object.assign({}, lynx.__initData, updateData); + shouldDelayUiOps.value = true; render(__root.__jsx, __root as any); if (__PROFILE__) { diff --git a/packages/react/runtime/src/lifecycleConstant.ts b/packages/react/runtime/src/lifecycleConstant.ts index aaf25f033d..de5d956ce1 100644 --- a/packages/react/runtime/src/lifecycleConstant.ts +++ b/packages/react/runtime/src/lifecycleConstant.ts @@ -5,7 +5,6 @@ export class LifecycleConstant { public static readonly firstScreen = 'rLynxFirstScreen'; public static readonly updateFromRoot = 'updateFromRoot'; public static readonly globalEventFromLepus = 'globalEventFromLepus'; - public static readonly ref = 'rLynxRef'; public static readonly jsReady = 'rLynxJSReady'; public static readonly patchUpdate = 'rLynxChange'; } diff --git a/packages/react/runtime/src/list.ts b/packages/react/runtime/src/list.ts index 9049146d75..973f1469bc 100644 --- a/packages/react/runtime/src/list.ts +++ b/packages/react/runtime/src/list.ts @@ -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 { hydrate } from './hydrate.js'; -import { commitMainThreadPatchUpdate } from './lifecycle/patch/updateMainThread.js'; import type { SnapshotInstance } from './snapshot.js'; export interface ListUpdateInfo { @@ -339,7 +338,6 @@ export function componentAtIndexFactory( __FlushElementTree(root, flushOptions); } signMap.set(sign, childCtx); - commitMainThreadPatchUpdate(undefined); return sign; } @@ -360,7 +358,6 @@ export function componentAtIndexFactory( }); } signMap.set(sign, childCtx); - commitMainThreadPatchUpdate(undefined); return sign; }; diff --git a/packages/react/runtime/src/lynx/calledByNative.ts b/packages/react/runtime/src/lynx/calledByNative.ts index ecf29588a0..46a451e8a8 100644 --- a/packages/react/runtime/src/lynx/calledByNative.ts +++ b/packages/react/runtime/src/lynx/calledByNative.ts @@ -9,7 +9,6 @@ import { LifecycleConstant } from '../lifecycleConstant.js'; import { __pendingListUpdates } from '../list.js'; import { ssrHydrateByOpcodes } from '../opcodes.js'; import { __root, setRoot } from '../root.js'; -import { takeGlobalRefPatchMap } from '../snapshot/ref.js'; import { SnapshotInstance, __page, setupPage } from '../snapshot.js'; import { isEmptyObject } from '../utils.js'; import { PerformanceTimingKeys, markTiming, setPipeline } from './performance.js'; @@ -118,9 +117,6 @@ function updatePage(data: any, options?: UpdatePageOption): void { markTiming(PerformanceTimingKeys.updateDiffVdomStart); { __pendingListUpdates.clear(); - - // ignore ref & unref before jsReady - takeGlobalRefPatchMap(); renderMainThread(); // As said by codename `jsReadyEventIdSwap`, this swap will only be used for event remap, // because ref & unref cause by previous render will be ignored diff --git a/packages/react/runtime/src/lynx/tt.ts b/packages/react/runtime/src/lynx/tt.ts index 22679d9302..dc8a2a2f97 100644 --- a/packages/react/runtime/src/lynx/tt.ts +++ b/packages/react/runtime/src/lynx/tt.ts @@ -18,10 +18,10 @@ import { delayedEvents, delayedPublishEvent } from '../lifecycle/event/delayEven import { delayLifecycleEvent, delayedLifecycleEvents } from '../lifecycle/event/delayLifecycleEvents.js'; import { commitPatchUpdate, genCommitTaskId, globalCommitTaskMap } from '../lifecycle/patch/commit.js'; import type { PatchList } from '../lifecycle/patch/commit.js'; +import { runDelayedUiOps } from '../lifecycle/ref/delay.js'; import { reloadBackground } from '../lifecycle/reload.js'; import { CHILDREN } from '../renderToOpcodes/constants.js'; import { __root } from '../root.js'; -import { globalRefsToSet, updateBackgroundRefs } from '../snapshot/ref.js'; import { backgroundSnapshotInstanceManager } from '../snapshot.js'; import { destroyWorklet } from '../worklet/destroy.js'; @@ -72,7 +72,7 @@ function onLifecycleEvent([type, data]: [string, any]) { function onLifecycleEventImpl(type: string, data: any): void { switch (type) { case LifecycleConstant.firstScreen: { - const { root: lepusSide, refPatch, jsReadyEventIdSwap } = data; + const { root: lepusSide, jsReadyEventIdSwap } = data; if (__PROFILE__) { console.profile('hydrate'); } @@ -109,16 +109,6 @@ function onLifecycleEventImpl(type: string, data: any): void { lynxCoreInject.tt.publishEvent = publishEvent; lynxCoreInject.tt.publicComponentEvent = publicComponentEvent; - if (__PROFILE__) { - console.profile('patchRef'); - } - if (refPatch) { - globalRefsToSet.set(0, JSON.parse(refPatch)); - updateBackgroundRefs(0); - } - if (__PROFILE__) { - console.profileEnd(); - } // console.debug("********** After hydration:"); // printSnapshotInstance(__root as BackgroundSnapshotInstance); if (__PROFILE__) { @@ -131,7 +121,6 @@ function onLifecycleEventImpl(type: string, data: any): void { const obj = commitPatchUpdate(patchList, { isHydration: true }); lynx.getNativeApp().callLepusMethod(LifecycleConstant.patchUpdate, obj, () => { - updateBackgroundRefs(commitTaskId); globalCommitTaskMap.forEach((commitTask, id) => { if (id > commitTaskId) { return; @@ -140,6 +129,7 @@ function onLifecycleEventImpl(type: string, data: any): void { globalCommitTaskMap.delete(id); }); }); + runDelayedUiOps(); break; } case LifecycleConstant.globalEventFromLepus: { @@ -147,16 +137,6 @@ function onLifecycleEventImpl(type: string, data: any): void { lynx.getJSModule('GlobalEventEmitter').trigger(eventName, params); break; } - case LifecycleConstant.ref: { - const { refPatch, commitTaskId } = data; - if (commitTaskId) { - globalRefsToSet.set(commitTaskId, JSON.parse(refPatch)); - } else { - globalRefsToSet.set(0, JSON.parse(refPatch)); - updateBackgroundRefs(0); - } - break; - } } } diff --git a/packages/react/runtime/src/snapshot.ts b/packages/react/runtime/src/snapshot.ts index 30ece552d1..846eef8b4d 100644 --- a/packages/react/runtime/src/snapshot.ts +++ b/packages/react/runtime/src/snapshot.ts @@ -166,7 +166,7 @@ export const backgroundSnapshotInstanceManager: { if (!res || (res.length != 2 && res.length != 3)) { throw new Error('Invalid ctx format: ' + str); } - let id = Number(res[0]); + const id = Number(res[0]); const expIndex = Number(res[1]); const ctx = this.values.get(id); if (!ctx) { @@ -266,7 +266,6 @@ export class SnapshotInstance { __element_root?: FiberElement | undefined; __values?: any[] | undefined; __current_slot_index = 0; - __ref_set?: Set; __worklet_ref_set?: Set | Worklet>; __listItemPlatformInfo?: any; @@ -289,15 +288,15 @@ export class SnapshotInstance { // CSS Scope is removed(We only need to call `__SetCSSId` when there is `entryName`) // Or an old bundle(`__SetCSSId` is called in `create`), we skip calling `__SetCSSId` if (entryName !== DEFAULT_ENTRY_NAME && entryName !== undefined) { - __SetCSSId(this.__elements!, DEFAULT_CSS_ID, entryName); + __SetCSSId(this.__elements, DEFAULT_CSS_ID, entryName); } } else { // cssId !== undefined if (entryName !== DEFAULT_ENTRY_NAME && entryName !== undefined) { // For lazy bundle, we need add `entryName` to the third params - __SetCSSId(this.__elements!, cssId, entryName); + __SetCSSId(this.__elements, cssId, entryName); } else { - __SetCSSId(this.__elements!, cssId); + __SetCSSId(this.__elements, cssId); } } @@ -554,7 +553,6 @@ export class SnapshotInstance { return; } - // TODO: ref: can this be done on the background thread? unref(child, true); r(); if (this.__elements) { diff --git a/packages/react/runtime/src/snapshot/ref.ts b/packages/react/runtime/src/snapshot/ref.ts index 248be086af..26b31230f4 100644 --- a/packages/react/runtime/src/snapshot/ref.ts +++ b/packages/react/runtime/src/snapshot/ref.ts @@ -3,21 +3,18 @@ // LICENSE file in the root directory of this source tree. import type { Worklet, WorkletRef } from '@lynx-js/react/worklet-runtime/bindings'; -import { nextCommitTaskId } from '../lifecycle/patch/commit.js'; -import { SnapshotInstance, backgroundSnapshotInstanceManager } from '../snapshot.js'; +import type { SnapshotInstance } from '../snapshot.js'; import { workletUnRef } from './workletRef.js'; +import type { BackgroundSnapshotInstance } from '../backgroundSnapshot.js'; +import { RefProxy } from '../lifecycle/ref/delay.js'; -let globalRefPatch: Record = {}; -const globalRefsToRemove: Map> = /* @__PURE__ */ new Map(); -const globalRefsToSet: Map> = /* @__PURE__ */ new Map(); -let nextRefId = 1; +const refsToApply: (Ref[] | BackgroundSnapshotInstance)[] = []; -function unref(snapshot: SnapshotInstance, recursive: boolean): void { - snapshot.__ref_set?.forEach(v => { - globalRefPatch[v] = null; - }); - snapshot.__ref_set?.clear(); +type Ref = (((ref: RefProxy) => () => void) | { current: RefProxy | null }) & { + _unmount?: () => void; +}; +function unref(snapshot: SnapshotInstance, recursive: boolean): void { snapshot.__worklet_ref_set?.forEach(v => { if (v) { workletUnRef(v as Worklet | WorkletRef); @@ -32,91 +29,60 @@ function unref(snapshot: SnapshotInstance, recursive: boolean): void { } } -function applyRef(ref: any, value: any) { - // TODO: ref: exceptions thrown in user functions should be able to be caught by an Error Boundary - if (typeof ref == 'function') { - const hasRefUnmount = typeof ref._unmount == 'function'; - if (hasRefUnmount) { - // @ts-ignore TS doesn't like moving narrowing checks into variables - ref._unmount(); - } +// This function is modified from preact source code. +function applyRef(ref: Ref, value: null | [snapshotInstanceId: number, expIndex: number]): void { + const newRef = value && new RefProxy(value); - if (!hasRefUnmount || value != null) { - // Store the cleanup function on the function - // instance object itself to avoid shape - // transitioning vnode - ref._unmount = ref(value); - } - } else ref.current = value; -} + try { + if (typeof ref == 'function') { + const hasRefUnmount = typeof ref._unmount == 'function'; + if (hasRefUnmount) { + ref._unmount!(); + } -function updateBackgroundRefs(commitId: number): void { - const oldRefMap = globalRefsToRemove.get(commitId); - if (oldRefMap) { - globalRefsToRemove.delete(commitId); - for (const ref of oldRefMap.values()) { - applyRef(ref, null); - } - } - const newRefMap = globalRefsToSet.get(commitId); - if (newRefMap) { - globalRefsToSet.delete(commitId); - for (const sign in newRefMap) { - const ref = backgroundSnapshotInstanceManager.getValueBySign(sign); - if (ref) { - // TODO: ref: support __REF_FIRE_IMMEDIATELY__ - const v = newRefMap[sign] && lynx.createSelectorQuery().selectUniqueID(newRefMap[sign]); - applyRef(ref, v); + if (!hasRefUnmount || newRef != null) { + // Store the cleanup function on the function + // instance object itself to avoid shape + // transitioning vnode + ref._unmount = ref(newRef!); } - } + } else ref.current = newRef; + /* v8 ignore start */ + } catch (e) { + lynx.reportError(e as Error); } + /* v8 ignore stop */ } function updateRef( snapshot: SnapshotInstance, expIndex: number, - oldValue: any, + _oldValue: any, elementIndex: number, - spreadKey: string, ): void { - const value = snapshot.__values![expIndex]; + const value: unknown = snapshot.__values![expIndex]; let ref; - if (!value) { - ref = undefined; - } else if (typeof value === 'string') { + if (typeof value === 'string') { ref = value; } else { - ref = `${snapshot.__id}:${expIndex}:${spreadKey}`; + ref = `react-ref-${snapshot.__id}-${expIndex}`; } snapshot.__values![expIndex] = ref; if (snapshot.__elements && ref) { - __SetAttribute(snapshot.__elements[elementIndex]!, 'has-react-ref', true); - const uid = __GetElementUniqueID(snapshot.__elements[elementIndex]!); - globalRefPatch[ref] = uid; - snapshot.__ref_set ??= new Set(); - snapshot.__ref_set.add(ref); - } - if (oldValue !== ref) { - snapshot.__ref_set?.delete(oldValue); + __SetAttribute(snapshot.__elements[elementIndex]!, ref, 1); } } -function takeGlobalRefPatchMap(): Record { - const patch = globalRefPatch; - globalRefPatch = {}; - return patch; -} - -function transformRef(ref: unknown): Function | (object & Record<'current', unknown>) | null | undefined { +function transformRef(ref: unknown): Ref | null | undefined { if (ref === undefined || ref === null) { return ref; } if (typeof ref === 'function' || (typeof ref === 'object' && 'current' in ref)) { if ('__ref' in ref) { - return ref; + return ref as Ref; } - return Object.defineProperty(ref, '__ref', { value: nextRefId++ }); + return Object.defineProperty(ref, '__ref', { value: 1 }) as Ref; } throw new Error( `Elements' "ref" property should be a function, or an object created ` @@ -124,25 +90,68 @@ function transformRef(ref: unknown): Function | (object & Record<'current', unkn ); } -function markRefToRemove(sign: string, ref: unknown): void { - if (!ref) { +/** + * Applies refs from a snapshot instance to their corresponding DOM elements. + * + * This function is called directly by preact with a `this` context of a Ref array that collects all + * refs that are applied during the process. + * + * @param snapshotInstance - The snapshot instance containing refs to apply + * + * If snapshotInstance is null, all previously collected refs are cleared. + * Otherwise, it iterates through the snapshot values, finds refs (either direct or in spreads), + * and applies them to their corresponding elements. + */ +function applyRefs(this: Ref[], snapshotInstance: BackgroundSnapshotInstance): void { + if (__LEPUS__) { + // for testing environment only return; } - let oldRefs = globalRefsToRemove.get(nextCommitTaskId); - if (!oldRefs) { - oldRefs = new Map(); - globalRefsToRemove.set(nextCommitTaskId, oldRefs); + refsToApply.push(this, snapshotInstance); +} + +function applyDelayedRefs(): void { + try { + for (let i = 0; i < refsToApply.length; i += 2) { + const refs = refsToApply[i] as Ref[]; + const snapshotInstance = refsToApply[i + 1] as BackgroundSnapshotInstance; + + if (snapshotInstance == null) { + try { + refs.forEach(ref => { + applyRef(ref, null); + }); + } finally { + refs.length = 0; + } + continue; + } + + for (let i = 0; i < snapshotInstance.__values!.length; i++) { + const value: unknown = snapshotInstance.__values![i]; + if (!value || (typeof value !== 'function' && typeof value !== 'object')) { + continue; + } + + let ref: Ref | undefined; + if ('__ref' in value) { + ref = value as Ref; + } else if ('__spread' in value) { + ref = (value as { ref?: Ref | undefined }).ref; + } + + if (ref) { + applyRef(ref, [snapshotInstance.__id, i]); + refs.push(ref); + } + } + } + } finally { + refsToApply.length = 0; } - oldRefs.set(sign, ref); } -export { - globalRefsToRemove, - globalRefsToSet, - markRefToRemove, - takeGlobalRefPatchMap, - transformRef, - unref, - updateBackgroundRefs, - updateRef, -}; +/** + * @internal + */ +export { updateRef, unref, transformRef, applyRef, applyRefs, applyDelayedRefs }; diff --git a/packages/react/runtime/src/snapshot/spread.ts b/packages/react/runtime/src/snapshot/spread.ts index 678c39ea55..403b84f056 100644 --- a/packages/react/runtime/src/snapshot/spread.ts +++ b/packages/react/runtime/src/snapshot/spread.ts @@ -91,7 +91,6 @@ function updateSpread(snapshot: SnapshotInstance, index: number, oldValue: any, } else if (key.startsWith('data-')) { // collected below } else if (key === 'ref') { - snapshot.__ref_set ??= new Set(); const fakeSnapshot = { __values: { get [index]() { @@ -104,9 +103,8 @@ function updateSpread(snapshot: SnapshotInstance, index: number, oldValue: any, }, __id: snapshot.__id, __elements: snapshot.__elements, - __ref_set: snapshot.__ref_set, } as SnapshotInstance; - updateRef(fakeSnapshot, index, oldValue[key], elementIndex, key); + updateRef(fakeSnapshot, index, oldValue[key], elementIndex); } else if (key.endsWith(':ref')) { snapshot.__worklet_ref_set ??= new Set(); const fakeSnapshot = { @@ -132,7 +130,7 @@ function updateSpread(snapshot: SnapshotInstance, index: number, oldValue: any, __elements: snapshot.__elements, } as SnapshotInstance; updateGesture(fakeSnapshot, index, oldValue[key], elementIndex, workletType); - } else if ((match = key.match(eventRegExp))) { + } else if ((match = eventRegExp.exec(key))) { const workletType = match[2]; const eventType = eventTypeMap[match[3]!]!; const eventName = match[4]!; @@ -179,7 +177,6 @@ function updateSpread(snapshot: SnapshotInstance, index: number, oldValue: any, } else if (key.startsWith('data-')) { // collected below } else if (key === 'ref') { - snapshot.__ref_set ??= new Set(); const fakeSnapshot = { __values: { get [index]() { @@ -192,9 +189,8 @@ function updateSpread(snapshot: SnapshotInstance, index: number, oldValue: any, }, __id: snapshot.__id, __elements: snapshot.__elements, - __ref_set: snapshot.__ref_set, } as SnapshotInstance; - updateRef(fakeSnapshot, index, oldValue[key], elementIndex, key); + updateRef(fakeSnapshot, index, oldValue[key], elementIndex); } else if (key.endsWith(':ref')) { snapshot.__worklet_ref_set ??= new Set(); const fakeSnapshot = { @@ -220,7 +216,7 @@ function updateSpread(snapshot: SnapshotInstance, index: number, oldValue: any, __elements: snapshot.__elements, } as SnapshotInstance; updateGesture(fakeSnapshot, index, oldValue[key], elementIndex, workletType); - } else if ((match = key.match(eventRegExp))) { + } else if ((match = eventRegExp.exec(key))) { const workletType = match[2]; const eventType = eventTypeMap[match[3]!]!; const eventName = match[4]!; @@ -274,8 +270,12 @@ function transformSpread( value ??= ''; result['className'] = value; } else if (key === 'ref') { - // @ts-ignore - result[key] = transformRef(value)?.__ref; + if (__LEPUS__) { + result[key] = value ? 1 : undefined; + } else { + // @ts-ignore + result[key] = transformRef(value)?.__ref; + } } else if (typeof value === 'function') { result[key] = `${snapshot.__id}:${index}:${key}`; } else { diff --git a/packages/react/runtime/src/snapshot/workletRef.ts b/packages/react/runtime/src/snapshot/workletRef.ts index 6628ef7dc1..5f55b84937 100644 --- a/packages/react/runtime/src/snapshot/workletRef.ts +++ b/packages/react/runtime/src/snapshot/workletRef.ts @@ -1,14 +1,10 @@ // 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 { - type Worklet, - type WorkletRef, - runWorkletCtx, - updateWorkletRef as update, -} from '@lynx-js/react/worklet-runtime/bindings'; +import { runWorkletCtx, updateWorkletRef as update } from '@lynx-js/react/worklet-runtime/bindings'; +import type { Worklet, WorkletRef } from '@lynx-js/react/worklet-runtime/bindings'; -import { SnapshotInstance } from '../snapshot.js'; +import type { SnapshotInstance } from '../snapshot.js'; function workletUnRef(value: Worklet | WorkletRef): void { if ('_wvid' in value) { @@ -42,10 +38,10 @@ function updateWorkletRef( if (value === null || value === undefined) { // do nothing } else if (value._wvid) { - update(value as any, snapshot.__elements[elementIndex]!); + update(value, snapshot.__elements[elementIndex]!); } else if (value._wkltId) { // @ts-ignore - value._unmount = runWorkletCtx(value as any, [{ elementRefptr: snapshot.__elements[elementIndex]! }]); + value._unmount = runWorkletCtx(value, [{ elementRefptr: snapshot.__elements[elementIndex]! }]); } else if (value._type === '__LEPUS__' || value._lepusWorkletHash) { // During the initial render, we will not update the WorkletRef because the background thread is not ready yet. } else { diff --git a/packages/react/runtime/src/snapshotInstanceHydrationMap.ts b/packages/react/runtime/src/snapshotInstanceHydrationMap.ts new file mode 100644 index 0000000000..9cddf69b3e --- /dev/null +++ b/packages/react/runtime/src/snapshotInstanceHydrationMap.ts @@ -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. + +/** + * A map to store hydration states between snapshot instances. + * K->V: main thread snapshotInstance IDs -> background snapshotInstance IDs. + * + * The map is used by the ref system to translate between snapshot instance IDs when + * operations need to cross the thread boundary during the commit phase. + */ +const hydrationMap: Map = new Map(); + +/** + * @internal + */ +export { hydrationMap }; diff --git a/packages/react/testing-library/src/__tests__/act.test.jsx b/packages/react/testing-library/src/__tests__/act.test.jsx index e1bf4aec5e..ad324fc170 100644 --- a/packages/react/testing-library/src/__tests__/act.test.jsx +++ b/packages/react/testing-library/src/__tests__/act.test.jsx @@ -77,7 +77,7 @@ test('findByTestId returns the element', async () => { Hello world! @@ -88,7 +88,7 @@ test('findByTestId returns the element', async () => { expect(await findByTestId('foo')).toMatchInlineSnapshot(` Hello world! @@ -96,12 +96,12 @@ test('findByTestId returns the element', async () => { `); expect(ref.current).toMatchInlineSnapshot(` - NodesRef { - "_nodeSelectToken": { - "identifier": "1", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, } `); }); diff --git a/packages/react/testing-library/src/__tests__/events.test.jsx b/packages/react/testing-library/src/__tests__/events.test.jsx index 679c6f48f6..4dba02ef82 100644 --- a/packages/react/testing-library/src/__tests__/events.test.jsx +++ b/packages/react/testing-library/src/__tests__/events.test.jsx @@ -1,9 +1,10 @@ // cspell:disable import '@testing-library/jest-dom'; -import { test } from 'vitest'; -import { fireEvent, render } from '..'; +import { expect, test, vi } from 'vitest'; + import { createRef } from '@lynx-js/react'; -import { expect, vi } from 'vitest'; + +import { fireEvent, render } from '..'; const eventTypes = [ { @@ -74,29 +75,28 @@ eventTypes.forEach(({ type, events, elementType, init }, eventTypeIdx) => { if (eventTypeIdx === 0 && eventIdx === 0) { expect(ref).toMatchInlineSnapshot(` { - "current": NodesRef { - "_nodeSelectToken": { - "identifier": "1", - "type": 2, - }, - "_selectorQuery": {}, + "current": RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, } `); expect(ref.current.constructor.name).toMatchInlineSnapshot( - `"NodesRef"`, - ); - const element = __GetElementByUniqueId( - Number(ref.current._nodeSelectToken.identifier), + `"RefProxy"`, ); + const refId = `react-ref-${ref.current.refAttr[0]}-${ref.current.refAttr[1]}`; + const element = document.querySelector(`[${refId}]`); expect(element).toMatchInlineSnapshot(` `); expect(element.attributes).toMatchInlineSnapshot(` NamedNodeMap { - "has-react-ref": "true", + "react-ref-2-0": "1", } `); expect(element.eventMap).toMatchInlineSnapshot(` diff --git a/packages/react/testing-library/src/__tests__/ref.test.jsx b/packages/react/testing-library/src/__tests__/ref.test.jsx index f618a0030e..51664bf58a 100644 --- a/packages/react/testing-library/src/__tests__/ref.test.jsx +++ b/packages/react/testing-library/src/__tests__/ref.test.jsx @@ -51,10 +51,10 @@ describe('component ref', () => { @@ -66,23 +66,23 @@ describe('component ref', () => { expect(ref3.mock.calls).toMatchInlineSnapshot(` [ [ - NodesRef { - "_nodeSelectToken": { - "identifier": "3", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], ] `); expect(ref4.current).toMatchInlineSnapshot(` - NodesRef { - "_nodeSelectToken": { - "identifier": "4", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 1, + ], + "task": undefined, } `); expect(cleanup).toBeCalledTimes(0); @@ -121,10 +121,10 @@ describe('element ref', () => { @@ -132,23 +132,23 @@ describe('element ref', () => { expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - NodesRef { - "_nodeSelectToken": { - "identifier": "2", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], ] `); expect(ref2.current).toMatchInlineSnapshot(` - NodesRef { - "_nodeSelectToken": { - "identifier": "3", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 1, + ], + "task": undefined, } `); }); @@ -185,10 +185,10 @@ describe('element ref', () => { @@ -196,23 +196,23 @@ describe('element ref', () => { expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - NodesRef { - "_nodeSelectToken": { - "identifier": "2", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], ] `); expect(ref2.current).toMatchInlineSnapshot(` - NodesRef { - "_nodeSelectToken": { - "identifier": "3", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 1, + ], + "task": undefined, } `); }); @@ -244,10 +244,10 @@ describe('element ref', () => { @@ -255,23 +255,23 @@ describe('element ref', () => { expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - NodesRef { - "_nodeSelectToken": { - "identifier": "2", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], ] `); expect(ref2.current).toMatchInlineSnapshot(` - NodesRef { - "_nodeSelectToken": { - "identifier": "3", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 1, + ], + "task": undefined, } `); act(() => { @@ -281,12 +281,12 @@ describe('element ref', () => { expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - NodesRef { - "_nodeSelectToken": { - "identifier": "2", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], [ @@ -328,7 +328,7 @@ describe('element ref', () => { @@ -336,12 +336,12 @@ describe('element ref', () => { expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - NodesRef { - "_nodeSelectToken": { - "identifier": "2", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], ] @@ -355,12 +355,12 @@ describe('element ref', () => { expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - NodesRef { - "_nodeSelectToken": { - "identifier": "2", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], ] @@ -406,10 +406,10 @@ describe('element ref', () => { @@ -417,23 +417,23 @@ describe('element ref', () => { expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - NodesRef { - "_nodeSelectToken": { - "identifier": "2", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], ] `); expect(ref2.current).toMatchInlineSnapshot(` - NodesRef { - "_nodeSelectToken": { - "identifier": "3", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 1, + ], + "task": undefined, } `); expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1); @@ -441,12 +441,12 @@ describe('element ref', () => { expect(ref1.mock.calls).toMatchInlineSnapshot(` [ [ - NodesRef { - "_nodeSelectToken": { - "identifier": "2", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, }, ], ] diff --git a/packages/react/testing-library/src/__tests__/render.test.jsx b/packages/react/testing-library/src/__tests__/render.test.jsx index cfd62ca70a..60fcef559e 100644 --- a/packages/react/testing-library/src/__tests__/render.test.jsx +++ b/packages/react/testing-library/src/__tests__/render.test.jsx @@ -17,12 +17,12 @@ test('renders view into page', async () => { }; render(); expect(ref.current).toMatchInlineSnapshot(` - NodesRef { - "_nodeSelectToken": { - "identifier": "1", - "type": 2, - }, - "_selectorQuery": {}, + RefProxy { + "refAttr": [ + 2, + 0, + ], + "task": undefined, } `); }); diff --git a/packages/react/testing-library/src/fire-event.ts b/packages/react/testing-library/src/fire-event.ts index 602c9e8e7d..52f784d89d 100644 --- a/packages/react/testing-library/src/fire-event.ts +++ b/packages/react/testing-library/src/fire-event.ts @@ -1,12 +1,14 @@ // @ts-nocheck -import { fireEvent as domFireEvent, createEvent } from '@testing-library/dom'; +import { createEvent, fireEvent as domFireEvent } from '@testing-library/dom'; -let NodesRef = lynx.createSelectorQuery().selectUniqueID(-1).constructor; +const NodesRef = lynx.createSelectorQuery().selectUniqueID(-1).constructor; function getElement(elemOrNodesRef) { if (elemOrNodesRef instanceof NodesRef) { return __GetElementByUniqueId( Number(elemOrNodesRef._nodeSelectToken.identifier), ); + } else if ('refAttr' in elemOrNodesRef) { + return document.querySelector(`[react-ref-${elemOrNodesRef.refAttr[0]}-${elemOrNodesRef.refAttr[1]}]`); } else if (elemOrNodesRef?.constructor?.name === 'HTMLUnknownElement') { return elemOrNodesRef; } else { @@ -25,7 +27,7 @@ export const fireEvent: any = (elemOrNodesRef, ...args) => { const elem = getElement(elemOrNodesRef); - let ans = domFireEvent(elem, ...args); + const ans = domFireEvent(elem, ...args); if (isMainThread) { // switch back to main thread @@ -156,7 +158,7 @@ Object.keys(eventMap).forEach((key) => { lynxTestingEnv.switchToBackgroundThread(); const elem = getElement(elemOrNodesRef); - const eventType = init?.['eventType'] || 'bindEvent'; + const eventType = init?.eventType || 'bindEvent'; init = { eventType, eventName: key, diff --git a/packages/react/transform/src/swc_plugin_snapshot/mod.rs b/packages/react/transform/src/swc_plugin_snapshot/mod.rs index 86f80ae751..ab4523d62c 100644 --- a/packages/react/transform/src/swc_plugin_snapshot/mod.rs +++ b/packages/react/transform/src/swc_plugin_snapshot/mod.rs @@ -226,7 +226,7 @@ impl DynamicPart { element_index: Expr = i32_to_expr(element_index), ), AttrName::Ref => quote!( - "(snapshot, index, oldValue) => $runtime_id.updateRef(snapshot, index, oldValue, $element_index, '')" as Expr, + "(snapshot, index, oldValue) => $runtime_id.updateRef(snapshot, index, oldValue, $element_index)" as Expr, runtime_id: Expr = runtime_id.clone(), element_index: Expr = i32_to_expr(element_index), ), @@ -1306,6 +1306,8 @@ where let mut snapshot_values: Vec> = vec![]; let mut snapshot_values_has_attr = false; + let mut snapshot_values_has_ref = false; + let mut snapshot_values_has_spread = false; let mut snapshot_attrs: Vec = vec![]; let mut snapshot_children: Vec = vec![]; let mut snapshot_dynamic_part_def: Vec> = vec![]; @@ -1394,11 +1396,16 @@ where value } } else if let AttrName::Ref = attr_name { - quote!( - "$runtime_id.transformRef($value)" as Expr, - runtime_id: Expr = runtime_id.clone(), - value: Expr = value, - ) + snapshot_values_has_ref = true; + if target == TransformTarget::LEPUS { + quote!("1" as Expr) + } else { + quote!( + "$runtime_id.transformRef($value)" as Expr, + runtime_id: Expr = runtime_id.clone(), + value: Expr = value, + ) + } } else { value }), @@ -1427,6 +1434,7 @@ where expr: Box::new(value), })); snapshot_values_has_attr = true; + snapshot_values_has_spread = true; // snapshot_attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { // span: DUMMY_SP, // name, @@ -1584,6 +1592,21 @@ where name: JSXElementName::Ident(snapshot_id.clone()), span: node.span, attrs: { + if target != TransformTarget::LEPUS + && (snapshot_values_has_ref || snapshot_values_has_spread) + { + snapshot_attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { + span: DUMMY_SP, + name: JSXAttrName::Ident(IdentName::new("ref".into(), DUMMY_SP)), + value: Some(JSXAttrValue::JSXExprContainer(JSXExprContainer { + span: DUMMY_SP, + expr: JSXExpr::Expr(Box::new(quote!( + r#"$runtime_id.applyRefs.bind([])"# as Expr, + runtime_id: Expr = self.runtime_id.clone(), + ))), + })), + })) + }; if snapshot_values_has_attr { snapshot_attrs.push(JSXAttrOrSpread::JSXAttr(JSXAttr { span: DUMMY_SP, diff --git a/packages/react/transform/tests/__swc_snapshots__/src/swc_plugin_snapshot/mod.rs/basic_ref.js b/packages/react/transform/tests/__swc_snapshots__/src/swc_plugin_snapshot/mod.rs/basic_ref.js index d96d9a7aba..4086075f3f 100644 --- a/packages/react/transform/tests/__swc_snapshots__/src/swc_plugin_snapshot/mod.rs/basic_ref.js +++ b/packages/react/transform/tests/__swc_snapshots__/src/swc_plugin_snapshot/mod.rs/basic_ref.js @@ -12,11 +12,12 @@ const __snapshot_da39a_test_1 = require('@lynx-js/react/internal').createSnapsho el2 ]; }, [ - (snapshot, index, oldValue)=>require('@lynx-js/react/internal').updateRef(snapshot, index, oldValue, 1, '') + (snapshot, index, oldValue)=>require('@lynx-js/react/internal').updateRef(snapshot, index, oldValue, 1) ], null, undefined, globDynamicComponentEntry); function Comp() { const handleRef = ()=>{}; return _jsx(__snapshot_da39a_test_1, { + ref: require('@lynx-js/react/internal').applyRefs.bind([]), values: [ require('@lynx-js/react/internal').transformRef(handleRef) ] diff --git a/packages/rspeedy/core/CHANGELOG.md b/packages/rspeedy/core/CHANGELOG.md index 792aa502fd..470550e096 100644 --- a/packages/rspeedy/core/CHANGELOG.md +++ b/packages/rspeedy/core/CHANGELOG.md @@ -48,7 +48,7 @@ - Support CLI flag `--base` to specify the base path of the server. ([#387](https://github.com/lynx-family/lynx-stack/pull/387)) -- Support CLI option `--environment` to specify the name of environment to build ([#462](https://github.com/lynx-family/lynx-stack/pull/462)) +- Support CLI flag `--environment` to specify the name of environment to build ([#462](https://github.com/lynx-family/lynx-stack/pull/462)) - Select the most appropriate network interface. ([#457](https://github.com/lynx-family/lynx-stack/pull/457)) @@ -58,7 +58,7 @@ See [Node.js - TypeScript](https://nodejs.org/api/typescript.html) for more details. -- Support CLI option `--env-mode` to specify the env mode to load the `.env.[mode]` file. ([#453](https://github.com/lynx-family/lynx-stack/pull/453)) +- Support CLI flag `--env-mode` to specify the env mode to load the `.env.[mode]` file. ([#453](https://github.com/lynx-family/lynx-stack/pull/453)) - Support `dev.hmr` and `dev.liveReload`. ([#458](https://github.com/lynx-family/lynx-stack/pull/458)) diff --git a/packages/rspeedy/core/src/cli/commands.ts b/packages/rspeedy/core/src/cli/commands.ts index 9de85ca5aa..046f6650e0 100644 --- a/packages/rspeedy/core/src/cli/commands.ts +++ b/packages/rspeedy/core/src/cli/commands.ts @@ -2,6 +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 type { RsbuildMode } from '@rsbuild/core' import type { Command } from 'commander' import type { BuildOptions } from './build.js' @@ -14,6 +15,7 @@ export interface CommonOptions { config?: string envMode?: string noEnv?: boolean + mode?: RsbuildMode } function applyCommonOptions(command: Command) { @@ -30,6 +32,10 @@ function applyCommonOptions(command: Command) { '--no-env', 'disable loading `.env` files"', ) + .option( + '-m --mode ', + 'specify the build mode, can be `development`, `production` or `none`', + ) } export function apply(program: Command): Command { @@ -82,7 +88,6 @@ export function apply(program: Command): Command { const inspectCommand = program.command('inspect') inspectCommand .description('View the Rsbuild config and Rspack config of the project.') - .option('--mode ', 'specify the mode of Rsbuild', 'development') .option('--output ', 'specify inspect content output path') .option('--verbose', 'show full function definitions in output') .action((inspectOptions: InspectOptions) => diff --git a/packages/rspeedy/core/src/cli/init.ts b/packages/rspeedy/core/src/cli/init.ts index b306defa95..0b235a0961 100644 --- a/packages/rspeedy/core/src/cli/init.ts +++ b/packages/rspeedy/core/src/cli/init.ts @@ -42,5 +42,9 @@ export async function init( createRspeedyOptions.environment = options.environment } + if (options.mode) { + rspeedyConfig.mode = options.mode + } + return { createRspeedyOptions, configPath, rspeedyConfig } } diff --git a/packages/rspeedy/core/src/cli/inspect.ts b/packages/rspeedy/core/src/cli/inspect.ts index cc157c44a8..ed94893356 100644 --- a/packages/rspeedy/core/src/cli/inspect.ts +++ b/packages/rspeedy/core/src/cli/inspect.ts @@ -12,7 +12,6 @@ import { createRspeedy } from '../create-rspeedy.js' import { init } from './init.js' export interface InspectOptions extends CommonOptions { - mode?: 'production' | 'development' | undefined verbose?: boolean | undefined output?: string | undefined } diff --git a/packages/rspeedy/core/test/cli/build.test.ts b/packages/rspeedy/core/test/cli/build.test.ts index 7eeaeb23d8..ae135f02fb 100644 --- a/packages/rspeedy/core/test/cli/build.test.ts +++ b/packages/rspeedy/core/test/cli/build.test.ts @@ -269,4 +269,125 @@ describe('CLI - build', () => { expect(vi.mocked(gracefulExit)).toBeCalled() expect(vi.mocked(gracefulExit)).toBeCalledWith(1) }) + + describe('mode', () => { + test('with NODE_ENV="production"', async () => { + vi.stubEnv('NODE_ENV', 'production') + + const core = await import('@rsbuild/core') + core.createRsbuild = vi.fn().mockReturnValueOnce({ + build() { + return Promise.reject(new Error('Mocked Build Error')) + }, + addPlugins() { + return Promise.resolve() + }, + }) + + const program = new Command('test') + await build.call( + program, + join(fixturesRoot, 'hello-world'), + {}, + ) + + expect(core.createRsbuild).toBeCalledWith(expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + rsbuildConfig: expect.objectContaining({ + mode: undefined, + }), + })) + }) + + test('with --mode development', async () => { + vi.stubEnv('NODE_ENV', 'production') + + const core = await import('@rsbuild/core') + core.createRsbuild = vi.fn().mockReturnValueOnce({ + build() { + return Promise.reject(new Error('Mocked Build Error')) + }, + addPlugins() { + return Promise.resolve() + }, + }) + + const program = new Command('test') + await build.call( + program, + join(fixturesRoot, 'hello-world'), + { + mode: 'development', + }, + ) + + expect(core.createRsbuild).toBeCalledWith(expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + rsbuildConfig: expect.objectContaining({ + mode: 'development', + }), + })) + }) + + test('with --mode none', async () => { + vi.stubEnv('NODE_ENV', 'production') + + const core = await import('@rsbuild/core') + core.createRsbuild = vi.fn().mockReturnValueOnce({ + build() { + return Promise.reject(new Error('Mocked Build Error')) + }, + addPlugins() { + return Promise.resolve() + }, + }) + + const program = new Command('test') + await build.call( + program, + join(fixturesRoot, 'hello-world'), + { + mode: 'none', + }, + ) + + expect(core.createRsbuild).toBeCalledWith(expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + rsbuildConfig: expect.objectContaining({ + mode: 'none', + }), + })) + }) + + test('with --mode foo', async () => { + vi.stubEnv('NODE_ENV', 'production') + + const core = await import('@rsbuild/core') + core.createRsbuild = vi.fn().mockReturnValueOnce({ + build() { + return Promise.reject(new Error('Mocked Build Error')) + }, + addPlugins() { + return Promise.resolve() + }, + }) + + const program = new Command('test') + await build.call( + program, + join(fixturesRoot, 'hello-world'), + { + // @ts-expect-error mocked wrong mode + mode: 'foo', + }, + ) + + expect(core.createRsbuild).toBeCalledWith(expect.objectContaining({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + rsbuildConfig: expect.objectContaining({ + mode: 'foo', + }), + })) + }) + }) }) diff --git a/packages/rspeedy/core/test/cli/inspect.test.ts b/packages/rspeedy/core/test/cli/inspect.test.ts index 3f12110fbf..e75d41f2ad 100644 --- a/packages/rspeedy/core/test/cli/inspect.test.ts +++ b/packages/rspeedy/core/test/cli/inspect.test.ts @@ -153,7 +153,11 @@ describe('CLI - Inspect', () => { await expect( import(path.join(tmp, 'dist', '.rsbuild', 'rspeedy.config.js')), ) - .resolves.toStrictEqual(expect.objectContaining({ default: {} })) + .resolves.toStrictEqual(expect.objectContaining({ + default: { + mode: 'production', + }, + })) expect(existsSync(path.join( tmp, @@ -227,7 +231,11 @@ describe('CLI - Inspect', () => { await expect( import(path.join(tmp, 'dist', '.rsbuild', 'rspeedy.config.js')), ) - .resolves.toStrictEqual(expect.objectContaining({ default: {} })) + .resolves.toStrictEqual(expect.objectContaining({ + default: { + mode: 'development', + }, + })) expect(existsSync(path.join( tmp, diff --git a/packages/third-party/tailwind-preset/README.md b/packages/third-party/tailwind-preset/README.md new file mode 100644 index 0000000000..83804b8331 --- /dev/null +++ b/packages/third-party/tailwind-preset/README.md @@ -0,0 +1,58 @@ +# Lynx Tailwind Preset (V3) + +A [Tailwind V3](https://v3.tailwindcss.com/) CSS preset specifically designed for Lynx, ensuring that only CSS properties supported by Lynx are available as Tailwind utilities. + +> **⚠️ Experimental**\ +> This preset is currently in experimental stage as we are still exploring the best possible DX to write Tailwind upon Lynx. We welcome and encourage contributions from the community to help shape its future development. Your feedback, bug reports, and pull requests are invaluable in making this preset more robust and feature-complete. + +## Structure + +- `src/lynx.ts`: Main preset configuration that reverse-engineered [Tailwind's core plugins](https://github.com/tailwindlabs/tailwindcss/blob/v3/src/corePlugins.js). +- `src/plugins/lynx/`: Custom plugins as replacement when per-class customization are needed. +- `src/__tests__/`: Test files to ensure correct utility generation + +## Contributing + +### Getting Started + +```bash +# Install dependencies +pnpm install + +# Run tests +pnpm test +``` + +### Adding New Utilities + +#### 1. Check if the CSS property is supported by Lynx + +This can be verified in three ways: + +1. [`@lynx-js/css-defines`](https://www.npmjs.com/package/@lynx-js/css-defines), this is the most accurate list of CSS properties supported by Lynx, directly generated from the source of Lynx internal definitions and released along with each Lynx releases. +2. `csstype.d.ts` in `@lynx-js/types`, this is used as the types of inline styles (e.g. ``) but this is currently maintained manually. +3. Lynx's runtime behaviors. + +#### 2. Add/Remove it from the preset + +If it's part of a core Tailwind plugin: + +- Add it to `corePlugins` in `src/lynx.ts` +- Add the property to `supportedProperties` in `src/__tests__/config.test.ts` +- Add the utility mapping to `cssPropertyValueToTailwindUtility` + +If it needs custom handling e.g. Lynx only support a partial set of the core plugin defined classes, or we need extensions e.g. `.linear`: + +- Create a new plugin in `src/plugins/lynx/` +- Export it in `src/plugins/lynx/index.ts` +- Add it to the plugins array in `src/lynx.ts` + +#### 3. Adding Tests + +We test by using Tailwind CLI to build `src/__tests__/` demo project with our preset, then extracting all properties used in the generated utilities and verify if all used properties are allowed according to `@lynx-js/types`. + +To test new Tailwind utilities: + +1. Modify `testClasses` in `src/__tests__/test-content.tsx` +2. Modify `supportedProperties` or `allowedUnsupportedProperties` in `config.test.ts` +3. Run tests with `pnpm test` to verify with Vitest. diff --git a/packages/third-party/tailwind-preset/package.json b/packages/third-party/tailwind-preset/package.json index 9b393b6ddb..b56c7bc62d 100644 --- a/packages/third-party/tailwind-preset/package.json +++ b/packages/third-party/tailwind-preset/package.json @@ -12,11 +12,6 @@ "url": "https://github.com/lynx-family/lynx-stack.git", "directory": "packages/third-party/tailwind-preset" }, - "license": "Apache-2.0", - "author": { - "name": "Qingyu Wang", - "email": "colinwang.0616@gmail.com" - }, "type": "module", "exports": null, "main": "./lib/lynx.js", @@ -26,7 +21,13 @@ "CHANGELOG.md", "README.md" ], + "scripts": { + "test": "vitest", + "test:coverage": "vitest run --coverage" + }, "devDependencies": { + "@lynx-js/types": "^3.2.1", + "postcss": "^8.4.35", "tailwindcss": "^3.4.17" } } diff --git a/packages/third-party/tailwind-preset/src/__tests__/config.test.ts b/packages/third-party/tailwind-preset/src/__tests__/config.test.ts new file mode 100644 index 0000000000..f11825bfb0 --- /dev/null +++ b/packages/third-party/tailwind-preset/src/__tests__/config.test.ts @@ -0,0 +1,146 @@ +// 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 { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { beforeAll, describe, expect, test } from 'vitest'; + +import type { CSSProperties } from '@lynx-js/types'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe('Lynx Tailwind Preset', () => { + let compiledCSS = ''; + let usedProperties = new Set(); + + beforeAll(() => { + try { + const cwd = path.resolve(__dirname, '../../'); + const configPath = path.resolve(__dirname, 'tailwind.config.ts'); + const inputPath = path.resolve(__dirname, 'styles.css'); + const outputPath = path.resolve(__dirname, 'output.css'); + + // console.log('Working directory:', cwd); + // console.log('Config path:', configPath); + // console.log('Input path:', inputPath); + // console.log('Output path:', outputPath); + + // Use Tailwind CLI to build CSS + execSync( + `npx tailwindcss -i ${inputPath} -o ${outputPath} -c ${configPath}`, + { + cwd, + }, + ); + + // Read the generated CSS + compiledCSS = fs.readFileSync(outputPath, 'utf-8'); + + // Extract classes and properties + usedProperties = extractPropertiesFromCSS(compiledCSS); + + // Cleanup only if file exists + if (fs.existsSync(outputPath)) { + fs.unlinkSync(outputPath); + } + } catch (error) { + console.error('Failed to generate CSS:', error); + throw error; + } + }); + + describe('Test against allowed CSS Properties', () => { + test('all used properties are supported', () => { + const allowedProperties = [ + ...supportedProperties, + ...allowedUnsupportedProperties, + ]; + + // Check that all used properties are supported + for (const property of usedProperties) { + expect(allowedProperties).toContain(property); + } + }); + }); +}); + +// Helper function to convert kebab-case to camelCase +function kebabToCamel(str: string): string { + return str.replace( + /-([a-z])/g, + (_: string, letter: string) => letter.toUpperCase(), + ); +} + +// Helper function to extract CSS property names from generated utilities +function extractPropertiesFromCSS(css: string): Set { + const properties = new Set(); + const propertyRegex = /([a-z-]+):/gi; + let match; + + while ((match = propertyRegex.exec(css)) !== null) { + if (match[1] && !match[1].startsWith('--tw-')) { + properties.add(kebabToCamel(match[1])); + } + } + + return properties; +} + +/** + * Get all supported CSS properties from @lynx-js/types + * ideally this should be generated from the {@link CSSProperties} type + * or {@link https://www.npmjs.com/package/@lynx-js/css-define} + */ +const supportedProperties: Array = [ + 'position', + 'boxSizing', + 'display', + 'overflow', + 'whiteSpace', + 'textAlign', + 'textOverflow', + 'fontWeight', + 'flexDirection', + 'flexWrap', + 'alignContent', + 'alignItems', + 'justifyContent', + 'fontStyle', + 'transform', + 'animationTimingFunction', + 'borderStyle', + 'transformOrigin', + 'linearOrientation', + 'linearGravity', + 'linearLayoutGravity', + 'layoutAnimationCreateTimingFunction', + 'layoutAnimationCreateProperty', + 'layoutAnimationDeleteTimingFunction', + 'layoutAnimationDeleteProperty', + 'layoutAnimationUpdateTimingFunction', + 'textDecoration', + 'visibility', + 'transitionProperty', + 'transitionTimingFunction', + 'borderLeftStyle', + 'borderRightStyle', + 'borderTopStyle', + 'borderBottomStyle', + 'overflowX', + 'overflowY', + 'wordBreak', + 'outlineStyle', + 'verticalAlign', + 'direction', + 'relativeCenter', + 'linearCrossGravity', + 'listMainAxisGap', +]; + +const allowedUnsupportedProperties = [ + 'overflowWrap', +]; diff --git a/packages/third-party/tailwind-preset/src/__tests__/styles.css b/packages/third-party/tailwind-preset/src/__tests__/styles.css new file mode 100644 index 0000000000..b5c61c9567 --- /dev/null +++ b/packages/third-party/tailwind-preset/src/__tests__/styles.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/packages/third-party/tailwind-preset/src/__tests__/tailwind.config.ts b/packages/third-party/tailwind-preset/src/__tests__/tailwind.config.ts new file mode 100644 index 0000000000..65c69baad4 --- /dev/null +++ b/packages/third-party/tailwind-preset/src/__tests__/tailwind.config.ts @@ -0,0 +1,18 @@ +// 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 path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import lynxPreset from '../lynx.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default { + presets: [lynxPreset], + content: [ + path.resolve(__dirname, 'test-content.tsx'), + path.resolve(__dirname, 'styles.css'), + ], +}; diff --git a/packages/third-party/tailwind-preset/src/__tests__/test-content.tsx b/packages/third-party/tailwind-preset/src/__tests__/test-content.tsx new file mode 100644 index 0000000000..d80cb50d07 --- /dev/null +++ b/packages/third-party/tailwind-preset/src/__tests__/test-content.tsx @@ -0,0 +1,6 @@ +// 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. + +export const testClasses = + 'box-border box-content fixed absolute relative sticky flex grid flex-row flex-row-reverse flex-col flex-col-reverse flex-wrap flex-wrap-reverse items-stretch items-start items-center items-end items-baseline content-start content-center content-end content-between content-around justify-start justify-center justify-end justify-between justify-around visible invisible hidden block overflow-hidden overflow-visible overflow-x-hidden overflow-x-visible overflow-y-hidden overflow-y-visible italic not-italic font-normal font-bold text-left text-center text-right underline line-through no-underline break-normal break-all truncate whitespace-normal border-solid border-dashed border-dotted border-double border-none flex-nowrap content-stretch justify-evenly justify-stretch text-start text-end whitespace-nowrap break-keep collapse ltr rtl'; diff --git a/packages/third-party/tailwind-preset/src/lynx.ts b/packages/third-party/tailwind-preset/src/lynx.ts index bc36476a3d..c54c1a790e 100644 --- a/packages/third-party/tailwind-preset/src/lynx.ts +++ b/packages/third-party/tailwind-preset/src/lynx.ts @@ -2,13 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { display, position, underline } from './plugins/lynx/index.js'; +import { display, position, textDecoration } from './plugins/lynx/index.js'; /** * Should be used with Tailwind JIT and configured with purge, otherwise the bundle will be too large. */ export default { - plugins: [position, underline, display], + plugins: [position, textDecoration, display], corePlugins: [ // 'preflight', 'alignContent', @@ -76,7 +76,7 @@ export default { 'zIndex', 'textAlign', - // 'textDecoration', // Defined using plugin + // 'textDecoration', // Replaced with plugin 'textOverflow', 'transformOrigin', @@ -93,6 +93,7 @@ export default { 'visibility', 'whitespace', + 'wordBreak', 'gridColumn', 'gridColumnStart', @@ -151,8 +152,6 @@ export default { // 'overscrollBehavior' -// 'wordBreak' - // 'backgroundOpacity' // 'gradientColorStops' // 'boxDecorationBreak' diff --git a/packages/third-party/tailwind-preset/src/plugins/lynx/display.ts b/packages/third-party/tailwind-preset/src/plugins/lynx/display.ts index b6b5624c61..030f5bb81d 100644 --- a/packages/third-party/tailwind-preset/src/plugins/lynx/display.ts +++ b/packages/third-party/tailwind-preset/src/plugins/lynx/display.ts @@ -12,7 +12,10 @@ export const display: void = createPlugin(({ addUtilities, variants }) => { '.block': { display: 'block' }, '.flex': { display: 'flex' }, '.grid': { display: 'grid' }, + '.hidden': { display: 'none' }, // Below are not supported by Lynx: + // - anything with 'inline' + // - anything with 'table' // '.inline': { display: 'inline', }, // '.list-item': { display: 'list-item', }, }, diff --git a/packages/third-party/tailwind-preset/src/plugins/lynx/index.ts b/packages/third-party/tailwind-preset/src/plugins/lynx/index.ts index 46452203cb..146ec847e9 100644 --- a/packages/third-party/tailwind-preset/src/plugins/lynx/index.ts +++ b/packages/third-party/tailwind-preset/src/plugins/lynx/index.ts @@ -2,6 +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. -export * from './position.js'; -export * from './underline.js'; -export * from './display.js'; +export { display } from './display.js'; +export { position } from './position.js'; +export { textDecoration } from './textDecoration.js'; diff --git a/packages/third-party/tailwind-preset/src/plugins/lynx/underline.ts b/packages/third-party/tailwind-preset/src/plugins/lynx/textDecoration.ts similarity index 64% rename from packages/third-party/tailwind-preset/src/plugins/lynx/underline.ts rename to packages/third-party/tailwind-preset/src/plugins/lynx/textDecoration.ts index f4ddbc9134..37ebf45e39 100644 --- a/packages/third-party/tailwind-preset/src/plugins/lynx/underline.ts +++ b/packages/third-party/tailwind-preset/src/plugins/lynx/textDecoration.ts @@ -5,9 +5,15 @@ /* eslint-disable @typescript-eslint/unbound-method */ import { createPlugin } from '../../helpers.js'; -export const underline: void = createPlugin(({ addUtilities }) => { +export const textDecoration: void = createPlugin(({ addUtilities }) => { addUtilities( { + '.no-underline': { + 'text-decoration': 'none', + }, + '.line-through': { + 'text-decoration': 'line-through', + }, '.underline': { 'text-decoration': 'underline', }, diff --git a/packages/third-party/tailwind-preset/vitest.config.ts b/packages/third-party/tailwind-preset/vitest.config.ts new file mode 100644 index 0000000000..b029760026 --- /dev/null +++ b/packages/third-party/tailwind-preset/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + name: 'tools/tailwind-preset', + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + globals: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69638059de..58092fcff5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -587,6 +587,12 @@ importers: packages/third-party/tailwind-preset: devDependencies: + '@lynx-js/types': + specifier: ^3.2.1 + version: 3.2.1 + postcss: + specifier: ^8.4.35 + version: 8.5.3 tailwindcss: specifier: ^3.4.17 version: 3.4.17 diff --git a/vitest.workspace.json b/vitest.workspace.json index 9fd0bc750a..3872a87532 100644 --- a/vitest.workspace.json +++ b/vitest.workspace.json @@ -4,5 +4,6 @@ "packages/tools/*/vitest.config.ts", "packages/webpack/*/vitest.config.ts", "packages/testing-library/*/vitest.config.mts", - "packages/testing-library/examples/*/vitest.config.ts" + "packages/testing-library/examples/*/vitest.config.ts", + "packages/third-party/*/vitest.config.ts" ] diff --git a/website/docs/en/guide/cli.md b/website/docs/en/guide/cli.md index b262703cfd..4ec820d283 100644 --- a/website/docs/en/guide/cli.md +++ b/website/docs/en/guide/cli.md @@ -71,13 +71,16 @@ The `rspeedy dev` command is used to start a local dev server and compile the so Usage: rspeedy dev [options] +Run the dev server and watch for source file changes while serving. + Options: - -b --base specify the base path of the server - -c --config specify the configuration file, can be a relative or absolute path - --env-mode specify the env mode to load the .env.[mode] file - --environment specify the name of environment to build - --no-env disable loading `.env` files" - -h, --help display help for command + --base specify the base path of the server + --environment specify the name of environment to build + -c --config specify the configuration file, can be a relative or absolute path + --env-mode specify the env mode to load the .env.[mode] file + --no-env disable loading `.env` files" + -m --mode specify the build mode, can be `development`, `production` or `none` + -h, --help display help for command ``` The dev server will restart automatically when the content of the configuration file is modified. @@ -91,12 +94,15 @@ The `rspeedy build` command will build the outputs for production in the `dist/` Usage: rspeedy build [options] +Build the project in production mode + Options: - -c --config specify the configuration file, can be a relative or absolute path - --env-mode specify the env mode to load the .env.[mode] file - --environment specify the name of environment to build - --no-env disable loading `.env` files" - -h, --help display help for command + --environment specify the name of environment to build + -c --config specify the configuration file, can be a relative or absolute path + --env-mode specify the env mode to load the .env.[mode] file + --no-env disable loading `.env` files" + -m --mode specify the build mode, can be `development`, `production` or `none` + -h, --help display help for command ``` ## rspeedy preview @@ -108,11 +114,14 @@ The `rspeedy preview` command is used to preview the production build outputs lo Usage: rspeedy preview [options] +Preview the production build outputs locally. + Options: - -b --base specify the base path of the server + --base specify the base path of the server -c --config specify the configuration file, can be a relative or absolute path --env-mode specify the env mode to load the .env.[mode] file --no-env disable loading `.env` files" + -m --mode specify the build mode, can be `development`, `production` or `none` -h, --help display help for command ``` @@ -132,12 +141,12 @@ Usage: rspeedy inspect [options] View the Rsbuild config and Rspack config of the project. Options: - --mode specify the mode of Rsbuild (default: "development") --output specify inspect content output path --verbose show full function definitions in output -c --config specify the configuration file, can be a relative or absolute path --env-mode specify the env mode to load the .env.[mode] file --no-env disable loading `.env` files" + -m --mode specify the build mode, can be `development`, `production` or `none` -h, --help display help for command ``` diff --git a/website/docs/zh/guide/cli.md b/website/docs/zh/guide/cli.md index 1c367acf71..3351087b22 100644 --- a/website/docs/zh/guide/cli.md +++ b/website/docs/zh/guide/cli.md @@ -69,13 +69,16 @@ Commands: Usage: rspeedy dev [options] +Run the dev server and watch for source file changes while serving. + Options: - -b --base specify the base path of the server - -c --config specify the configuration file, can be a relative or absolute path - --env-mode specify the env mode to load the .env.[mode] file - --environment specify the name of environment to build - --no-env disable loading `.env` files" - -h, --help display help for command + --base specify the base path of the server + --environment specify the name of environment to build + -c --config specify the configuration file, can be a relative or absolute path + --env-mode specify the env mode to load the .env.[mode] file + --no-env disable loading `.env` files" + -m --mode specify the build mode, can be `development`, `production` or `none` + -h, --help display help for command ``` 当配置文件内容发生修改时,开发服务器会自动重启。 @@ -89,12 +92,15 @@ Options: Usage: rspeedy build [options] +Build the project in production mode + Options: - -c --config specify the configuration file, can be a relative or absolute path - --env-mode specify the env mode to load the .env.[mode] file - --environment specify the name of environment to build - --no-env disable loading `.env` files" - -h, --help display help for command + --environment specify the name of environment to build + -c --config specify the configuration file, can be a relative or absolute path + --env-mode specify the env mode to load the .env.[mode] file + --no-env disable loading `.env` files" + -m --mode specify the build mode, can be `development`, `production` or `none` + -h, --help display help for command ``` ## rspeedy preview @@ -106,11 +112,14 @@ Options: Usage: rspeedy preview [options] +Preview the production build outputs locally. + Options: - -b --base specify the base path of the server + --base specify the base path of the server -c --config specify the configuration file, can be a relative or absolute path --env-mode specify the env mode to load the .env.[mode] file --no-env disable loading `.env` files" + -m --mode specify the build mode, can be `development`, `production` or `none` -h, --help display help for command ``` @@ -130,12 +139,12 @@ Usage: rspeedy inspect [options] View the Rsbuild config and Rspack config of the project. Options: - --mode specify the mode of Rsbuild (default: "development") --output specify inspect content output path --verbose show full function definitions in output -c --config specify the configuration file, can be a relative or absolute path --env-mode specify the env mode to load the .env.[mode] file --no-env disable loading `.env` files" + -m --mode specify the build mode, can be `development`, `production` or `none` -h, --help display help for command ```