From c70d3979b92a9e79b3c64ed95b4d5dab06ae213a Mon Sep 17 00:00:00 2001 From: HoikanChan Date: Mon, 8 Sep 2025 20:13:02 +0800 Subject: [PATCH 1/2] feat: useSyncExternalStore --- .../src/compat/UseSyncExternalStoreHook.ts | 178 +++++ packages/inula/src/index.ts | 5 + .../CompactTest/UseSyncExternalStore.test.tsx | 709 ++++++++++++++++++ packages/inula/tests/CompactTest/mockStore.ts | 111 +++ 4 files changed, 1003 insertions(+) create mode 100644 packages/inula/src/compat/UseSyncExternalStoreHook.ts create mode 100644 packages/inula/tests/CompactTest/UseSyncExternalStore.test.tsx create mode 100644 packages/inula/tests/CompactTest/mockStore.ts diff --git a/packages/inula/src/compat/UseSyncExternalStoreHook.ts b/packages/inula/src/compat/UseSyncExternalStoreHook.ts new file mode 100644 index 00000000..4f8b00a4 --- /dev/null +++ b/packages/inula/src/compat/UseSyncExternalStoreHook.ts @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +import { isSame } from '../renderer/utils/compare'; +import { + useState, + useRef, + useEffect, + useLayoutEffect, + useMemo +} from '../renderer/hooks/HookExternal'; + +type StoreChangeListener = () => void; +type Unsubscribe = () => void; +type Store = { value: T; getSnapshot: () => T } + +export function useSyncExternalStore( + subscribe: (onStoreChange: StoreChangeListener) => Unsubscribe, + getSnapshot: () => T, +): T { + // 获取当前Store快照 + const value = getSnapshot(); + + // 用于强制重新渲染和存储store引用,非普通state + // 需要强制更新时调用 `forceUpdate({inst})`,由于是新的对象引入,会导致组件强制更新 + const [{ store }, forceUpdate] = useState<{ store: Store }>({ + store: { + value, + getSnapshot + } + }); + + function reRenderIfStoreChange() { + if (didSnapshotChange(store)) { + forceUpdate({ store }); + } + } + + // 必须在 layout 阶段(绘制前)同步完成,以便后续的访问到正确的value。 + useLayoutEffect(() => { + // 同步更新 快照值和 getSnapshot 函数引用,确保它们始终是最新的。 + // subscribe 更新,代表数据源变化,也需要更新 + store.value = value; + store.getSnapshot = getSnapshot; + + reRenderIfStoreChange(); + }, [subscribe, value, getSnapshot]); + + + useEffect(() => { + // useLayoutEffect和useEffect存在时机,store可能会变化 + reRenderIfStoreChange(); + + // 开始订阅,返回值以在卸载组件是取消订阅 + return subscribe(reRenderIfStoreChange); + }, [subscribe]); + + + return value; +} + +/** + * 检查快照是否发生变化 + * @param store Store实例 + * @returns 返回Store状态是否发生变化 + */ +function didSnapshotChange(store: Store) { + const latestGetSnapshot = store.getSnapshot; + const prevValue = store.value; + try { + const nextValue = latestGetSnapshot(); + return !isSame(prevValue, nextValue); + } catch (error) { + return true; + } +} + +// 用于追踪已渲染快照的实例类型 +type SelectionInstance = + | { hasValue: true; value: Selection } + | { hasValue: false; value: null }; + +// 确保返回的类型既是原始类型 T,又确实是个函数 +function isFunction(value: T): value is T & ((...args: any[]) => any) { + return typeof value !== 'function'; +} + +// 与useSyncExternalStore相同,但支持选择器和相等性判断参数 +export function useSyncExternalStoreWithSelector( + subscribe: (onStoreChange: StoreChangeListener) => Unsubscribe, + getSnapshot: () => Snapshot, + getServerSnapshot: void | null | (() => Snapshot), + selector: (snapshot: Snapshot) => Selection, + isEqual?: (a: Selection, b: Selection) => boolean, +): Selection { + // 用于缓存已渲染的store快照值 + const instRef = useRef>({ + hasValue: false, + value: null + }); + + // 创建带选择器的快照获取函数 + const snapshotWithSelector = useMemo(() => { + // 使用闭包变量追踪缓存状态,在 getSnapshot / selector / isEqual 不变时 + let initialized = false; + let cachedSnapshot: Snapshot; + let cachedSelection: Selection; + + const memoizedSelectorFn = (snapshot: Snapshot) => { + // 首次调用时的处理 + if (!initialized) { + initialized = true; + cachedSnapshot = snapshot; + const selection = selector(snapshot); + + // 尝试复用当前渲染值 + if (isFunction(isEqual) && instRef.current.hasValue) { + const current = instRef.current.value; + if (isEqual(current, selection)) { + cachedSelection = current; + return current; + } + } + + cachedSelection = selection; + return selection; + } + + // 尝试复用之前的结果 + const previousSnapshot = cachedSnapshot; + const previousSelection = cachedSelection; + + // 快照未变化时直接返回之前的选择结果 + if (isSame(previousSnapshot, snapshot)) { + return previousSelection; + } + + // 快照已变化,需要计算新的选择结果 + const newSelection = selector(snapshot); + + // 使用自定义相等判断函数检查数据是否真正发生变化 + if (isFunction(isEqual) && isEqual(previousSelection, newSelection)) { + // 虽然选择结果相等,但仍需更新快照避免保留旧引用 + cachedSnapshot = snapshot; + return previousSelection; + } + + // 更新缓存并返回新结果 + cachedSnapshot = snapshot; + cachedSelection = newSelection; + return newSelection; + }; + + return () => memoizedSelectorFn(getSnapshot()); + }, [getSnapshot, selector, isEqual]); + + const selectedValue = useSyncExternalStore(subscribe, snapshotWithSelector); + + // 更新实例状态 + useEffect(() => { + instRef.current.hasValue = true; + instRef.current.value = selectedValue; + }, [selectedValue]); + + return selectedValue; +} \ No newline at end of file diff --git a/packages/inula/src/index.ts b/packages/inula/src/index.ts index c90d454b..a8bbaf22 100644 --- a/packages/inula/src/index.ts +++ b/packages/inula/src/index.ts @@ -73,6 +73,7 @@ import { import { syncUpdates as flushSync } from './renderer/TreeBuilder'; import { toRaw } from './inulax/proxy/ProxyHandler'; +import { useSyncExternalStore, useSyncExternalStoreWithSelector } from './compat/UseSyncExternalStoreHook'; const Inula = { Children, @@ -93,6 +94,8 @@ const Inula = { useReducer, useRef, useState, + useSyncExternalStore, + useSyncExternalStoreWithSelector, createElement, cloneElement, isValidElement, @@ -146,6 +149,8 @@ export { useReducer, useRef, useState, + useSyncExternalStore, + useSyncExternalStoreWithSelector, createElement, cloneElement, isValidElement, diff --git a/packages/inula/tests/CompactTest/UseSyncExternalStore.test.tsx b/packages/inula/tests/CompactTest/UseSyncExternalStore.test.tsx new file mode 100644 index 00000000..2f3c2075 --- /dev/null +++ b/packages/inula/tests/CompactTest/UseSyncExternalStore.test.tsx @@ -0,0 +1,709 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +/** @jsx Inula.createElement */ +import Inula, { useSyncExternalStore, useSyncExternalStoreWithSelector, act } from '../../src/index'; +import { createMockStore } from './mockStore'; +const { unmountComponentAtNode, render } = Inula; + +interface TodoItem { + id: number; + text: string; + completed: boolean; +} + +describe('useSyncExternalStore', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + unmountComponentAtNode(container); + document.body.removeChild(container); + }); + + describe('基础功能测试', () => { + it('should subscribe to external store and return current value', () => { + const store = createMockStore('initial'); + + function App(): JSX.Element { + const value = useSyncExternalStore(store.subscribe, store.getValue); + return
{`Value: ${value}`}
; + } + + act(() => { + render(, container); + }); + + expect(container.textContent).toBe('Value: initial'); + expect(store.getListenerCount()).toBe(1); + + act(() => { + unmountComponentAtNode(container); + }); + + expect(store.getListenerCount()).toBe(0); + }); + + it('should re-render when store value changes', () => { + const store = createMockStore(0); + + function App(): JSX.Element { + const count = useSyncExternalStore(store.subscribe, store.getValue); + return
{`Count: ${count}`}
; + } + + render(, container); + expect(container.textContent).toBe('Count: 0'); + + act(() => { + store.setValue(1); + }); + expect(container.textContent).toBe('Count: 1'); + + act(() => { + store.setValue(2); + }); + expect(container.textContent).toBe('Count: 2'); + }); + + it('should not re-render when store value does not change', () => { + const store = createMockStore(0); + let renderCount = 0; + + function App(): JSX.Element { + renderCount++; + const count = useSyncExternalStore(store.subscribe, store.getValue); + return
{`Count: ${count}`}
; + } + + render(, container); + expect(renderCount).toBe(1); + + act(() => { + store.setValue(0); // Same value + }); + expect(renderCount).toBe(1); // Should not re-render + }); + + it('should handle multiple rapid updates', () => { + const store = createMockStore(0); + + function App(): JSX.Element { + const count = useSyncExternalStore(store.subscribe, store.getValue); + return
{`Count: ${count}`}
; + } + + render(, container); + expect(container.textContent).toBe('Count: 0'); + + act(() => { + store.setValue(1); + store.setValue(2); + store.setValue(3); + }); + expect(container.textContent).toBe('Count: 3'); + }); + + it('should handle multiple components subscribing to same store', () => { + const store = createMockStore('shared'); + let comp1RenderCount = 0; + let comp2RenderCount = 0; + + function Component1(): JSX.Element { + comp1RenderCount++; + const value = useSyncExternalStore(store.subscribe, store.getValue); + return
{`Comp1: ${value}`}
; + } + + function Component2(): JSX.Element { + comp2RenderCount++; + const value = useSyncExternalStore(store.subscribe, store.getValue); + return
{`Comp2: ${value}`}
; + } + + function App(): JSX.Element { + return ( +
+ + +
+ ); + } + + act(() => { + render(, container); + }); + expect(store.getListenerCount()).toBe(2); + expect(comp1RenderCount).toBe(1); + expect(comp2RenderCount).toBe(1); + + act(() => { + store.setValue('updated'); + }); + + expect(comp1RenderCount).toBe(2); + expect(comp2RenderCount).toBe(2); + expect(container.querySelector('#comp1')!.textContent).toBe('Comp1: updated'); + expect(container.querySelector('#comp2')!.textContent).toBe('Comp2: updated'); + }); + + it('should re-subscribe when store changes', () => { + const store1 = createMockStore('store1'); + const store2 = createMockStore('store2'); + + function App({ useStore1 }: { useStore1: boolean }): JSX.Element { + const store = useStore1 ? store1 : store2; + const value = useSyncExternalStore(store.subscribe, store.getValue); + return
{`Value: ${value}`}
; + } + + act(() => { + render(, container); + }); + expect(container.textContent).toBe('Value: store1'); + expect(store1.getListenerCount()).toBe(1); + expect(store2.getListenerCount()).toBe(0); + + // Switch to store2 + act(() => { + render(, container); + }); + + expect(container.textContent).toBe('Value: store2'); + expect(store1.getListenerCount()).toBe(0); + expect(store2.getListenerCount()).toBe(1); + }); + + it('should update when getSnapshot function changes', () => { + const store = createMockStore({ a: 1, b: 2 }); + + interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + } + + class ErrorBoundary extends Inula.Component<{ children: JSX.Element }, ErrorBoundaryState> { + constructor(props: { children: JSX.Element }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(): void { + // Error handling for boundary + } + + render(): JSX.Element { + if (this.state.hasError) { + return
{`Error: ${this.state.error!.message}`}
; + } + return this.props.children; + } + } + + function App({ selectA }: { selectA: boolean }): JSX.Element { + const getSnapshot = selectA + ? () => store.getValue().a + : () => store.getValue().b; + const value = useSyncExternalStore(store.subscribe, getSnapshot); + return
{`Value: ${value}`}
; + } + + render( + + + , + container + ); + expect(container.textContent).toBe('Value: 1'); + + // Change selector + render( + + + , + container + ); + expect(container.textContent).toBe('Value: 2'); + }); + + it('selecting a specific value inside getSnapshot', () => { + const store = createMockStore({ + user: { name: 'John', age: 30 }, + items: [1, 2, 3] + }); + let errorCaught: Error | null = null; + + interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + } + + class ErrorBoundary extends Inula.Component<{ children: JSX.Element }, ErrorBoundaryState> { + constructor(props: { children: JSX.Element }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error): void { + errorCaught = error; + } + + render(): JSX.Element { + if (this.state.hasError) { + return
{`Error: ${this.state.error!.message}`}
; + } + return this.props.children; + } + } + + function App({ selector }: { selector: string }): JSX.Element { + const getSnapshot = () => { + const state = store.getValue(); + if (selector === 'name') return state.user.name; + if (selector === 'age') return state.user.age; + if (selector === 'itemCount') return state.items.length; + if (selector === 'error') throw new Error('getSnapshot selector error'); + return state; + }; + const value = useSyncExternalStore(store.subscribe, getSnapshot); + return
{`Value: ${JSON.stringify(value)}`}
; + } + + // Test selecting user name + render( + + + , + container + ); + expect(container.textContent).toBe('Value: "John"'); + + // Test selecting age + render( + + + , + container + ); + expect(container.textContent).toBe('Value: 30'); + + // Test selecting item count + render( + + + , + container + ); + expect(container.textContent).toBe('Value: 3'); + + // Test error in getSnapshot selector + render( + + + , + container + ); + expect(container.textContent).toBe('Error: getSnapshot selector error'); + expect(errorCaught).toBeTruthy(); + expect(errorCaught!.message).toBe('getSnapshot selector error'); + }); + + it('should handle getSnapshot throwing error', () => { + const store = createMockStore('initial'); + let errorCaught: Error | null = null; + + interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + } + + class ErrorBoundary extends Inula.Component<{ children: JSX.Element }, ErrorBoundaryState> { + constructor(props: { children: JSX.Element }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error): void { + errorCaught = error; + } + + render(): JSX.Element { + if (this.state.hasError) { + return
{`Error: ${this.state.error!.message}`}
; + } + return this.props.children; + } + } + + function App(): JSX.Element { + const value = useSyncExternalStore(store.subscribe, store.getValue); + return
{`Value: ${value}`}
; + } + + render( + + + , + container + ); + expect(container.textContent).toBe('Value: initial'); + + // Enable error in getSnapshot + store.enableSnapshotError(); + + act(() => { + store.setValue('trigger-error'); + }); + + expect(container.textContent).toBe('Error: getSnapshot error'); + expect(errorCaught).toBeTruthy(); + expect(errorCaught!.message).toBe('getSnapshot error'); + }); + }); + + describe('对象引用比较', () => { + it('should handle object reference changes', () => { + const store = createMockStore({ count: 0, name: 'test' }); + + function App(): JSX.Element { + const state = useSyncExternalStore(store.subscribe, store.getValue); + return
{`${state.count}-${state.name}`}
; + } + + render(, container); + expect(container.textContent).toBe('0-test'); + + act(() => { + store.updateProperty('count', 1); + }); + expect(container.textContent).toBe('1-test'); + + act(() => { + store.updateProperty('name', 'updated'); + }); + expect(container.textContent).toBe('1-updated'); + }); + + it('should not re-render for equivalent objects', () => { + const store = createMockStore({ a: 1, b: 2 }); + let renderCount = 0; + + function App(): JSX.Element { + renderCount++; + const obj = useSyncExternalStore(store.subscribe, store.getValue); + return
{JSON.stringify(obj)}
; + } + + render(, container); + expect(renderCount).toBe(1); + + // Set the same object reference - should not re-render + const currentValue = store.getValue(); + act(() => { + store.setValue(currentValue); + }); + expect(renderCount).toBe(1); + + // Set different object with same content - should re-render + act(() => { + store.setValue({ a: 1, b: 2 }); + }); + expect(renderCount).toBe(2); + }); + }); + + describe('useSyncExternalStoreWithSelector', () => { + it('should only re-render when selected value changes', () => { + const store = createMockStore({ count: 0, name: 'test', other: 'unchanged' }); + let renderCount = 0; + + function App(): JSX.Element { + renderCount++; + const count = useSyncExternalStoreWithSelector( + store.subscribe, + store.getValue, + store.getValue, + (state: any) => state.count + ); + return
{`Count: ${count}`}
; + } + + render(, container); + expect(container.textContent).toBe('Count: 0'); + expect(renderCount).toBe(1); + + // Update selected property - should re-render + act(() => { + store.updateProperty('count', 1); + }); + expect(container.textContent).toBe('Count: 1'); + expect(renderCount).toBe(2); + + // Update non-selected property - should NOT re-render + act(() => { + store.updateProperty('other', 'changed'); + }); + expect(container.textContent).toBe('Count: 1'); + expect(renderCount).toBe(2); // No re-render + + // Update selected property again + act(() => { + store.updateProperty('count', 2); + }); + expect(container.textContent).toBe('Count: 2'); + expect(renderCount).toBe(3); + }); + + it('should work with custom equality function', () => { + const store = createMockStore({ + user: { id: 1, name: 'John', age: 30 } + }); + let renderCount = 0; + + // Custom equality that only compares user.name + const isEqual = (a: any, b: any) => a.name === b.name; + + function App(): JSX.Element { + renderCount++; + const user = useSyncExternalStoreWithSelector( + store.subscribe, + store.getValue, + store.getValue, + (state: any) => state.user, + isEqual + ); + return
{`User: ${user?.name}-${user?.age}`}
; + } + + render(, container); + expect(container.textContent).toBe('User: John-30'); + expect(renderCount).toBe(1); + + // Change age but keep name same - should NOT re-render due to custom equality + act(() => { + store.updateProperty('user', { id: 1, name: 'John', age: 31 }); + }); + expect(renderCount).toBe(1); // No re-render + + // Change name - should re-render + act(() => { + store.updateProperty('user', { id: 1, name: 'Jane', age: 31 }); + }); + expect(container.textContent).toBe('User: Jane-31'); + expect(renderCount).toBe(2); + }); + + it('should handle complex object selection', () => { + const store = createMockStore({ + todos: [ + { id: 1, text: 'Task 1', completed: false }, + { id: 2, text: 'Task 2', completed: true }, + { id: 3, text: 'Task 3', completed: false } + ], + filter: 'all' + }); + + function App(): JSX.Element { + const incompleteTodos = useSyncExternalStoreWithSelector( + store.subscribe, + store.getValue, + store.getValue, + (state: any) => state.todos?.filter((todo: TodoItem) => !todo.completed) || [] + ); + return
{`Incomplete: ${incompleteTodos.length}`}
; + } + + render(, container); + expect(container.textContent).toBe('Incomplete: 2'); + + // Complete a todo + act(() => { + const currentState = store.getValue(); + const newTodos = (currentState as any).todos?.map((todo: TodoItem) => + todo.id === 1 ? { ...todo, completed: true } : todo + ) || []; + store.setValue({ ...currentState, todos: newTodos }); + }); + expect(container.textContent).toBe('Incomplete: 1'); + + // Change filter (doesn't affect selection) - should NOT re-render + const renderCountBefore = container.textContent; + act(() => { + store.updateProperty('filter', 'completed'); + }); + expect(container.textContent).toBe(renderCountBefore); // Same content + }); + + it('should maintain reference stability when selection result is equal', () => { + const store = createMockStore({ items: [1, 2, 3], version: 1 }); + const selections: any[] = []; + + function App(): JSX.Element { + const items = useSyncExternalStoreWithSelector( + store.subscribe, + store.getValue, + store.getValue, + (state: any) => state.items + ); + selections.push(items); + return
{`Items: ${items?.join(',') || ''}`}
; + } + + render(, container); + expect(container.textContent).toBe('Items: 1,2,3'); + + // Update version but keep items same + act(() => { + store.updateProperty('version', 2); + }); + + // Should maintain same reference for items + expect(selections.length).toBe(1); + expect(selections[0]).toBe(selections[0]); // Same reference + }); + + it('should work with multiple selectors on same store', () => { + const store = createMockStore({ + user: { name: 'John', email: 'john@example.com' }, + settings: { theme: 'dark', language: 'en' } + }); + + function UserName(): JSX.Element { + const name = useSyncExternalStoreWithSelector( + store.subscribe, + store.getValue, + store.getValue, + (state: any) => state.user?.name + ); + return
{`Name: ${name}`}
; + } + + function UserTheme(): JSX.Element { + const theme = useSyncExternalStoreWithSelector( + store.subscribe, + store.getValue, + store.getValue, + (state: any) => state.settings?.theme + ); + return
{`Theme: ${theme}`}
; + } + + function App(): JSX.Element { + return ( +
+ + +
+ ); + } + + render(, container); + expect(container.querySelector('#name')!.textContent).toBe('Name: John'); + expect(container.querySelector('#theme')!.textContent).toBe('Theme: dark'); + + // Update user name - only UserName should re-render + act(() => { + const currentState = store.getValue() as any; + store.setValue({ + ...currentState, + user: { ...currentState.user!, name: 'Jane' } + }); + }); + expect(container.querySelector('#name')!.textContent).toBe('Name: Jane'); + expect(container.querySelector('#theme')!.textContent).toBe('Theme: dark'); + + // Update theme - only UserTheme should re-render + act(() => { + const currentState = store.getValue() as any; + store.setValue({ + ...currentState, + settings: { ...currentState.settings!, theme: 'light' } + }); + }); + expect(container.querySelector('#name')!.textContent).toBe('Name: Jane'); + expect(container.querySelector('#theme')!.textContent).toBe('Theme: light'); + }); + + it('should handle selector function changes', () => { + const store = createMockStore({ a: 1, b: 2, c: 3 }); + + function App({ selectA }: { selectA: boolean }): JSX.Element { + const value = useSyncExternalStoreWithSelector( + store.subscribe, + store.getValue, + store.getValue, + selectA ? (state: any) => state.a : (state: any) => state.b + ); + return
{`Value: ${value}`}
; + } + + render(, container); + expect(container.textContent).toBe('Value: 1'); + + // Change selector function + render(, container); + expect(container.textContent).toBe('Value: 2'); + + // Update the now-selected property + act(() => { + store.updateProperty('b', 5); + }); + expect(container.textContent).toBe('Value: 5'); + }); + + it('should handle selector throwing errors', () => { + const store = createMockStore({ value: 'test' }); + + function App({ shouldThrow }: { shouldThrow: boolean }): JSX.Element { + const value = useSyncExternalStoreWithSelector( + store.subscribe, + store.getValue, + store.getValue, + (state: any) => { + if (shouldThrow) { + throw new Error('Selector error'); + } + return state.value; + } + ); + return
{`Value: ${value}`}
; + } + + render(, container); + expect(container.textContent).toBe('Value: test'); + + // Enable selector error + expect(() => { + render(, container); + }).toThrow('Selector error'); + }); + }); +}); \ No newline at end of file diff --git a/packages/inula/tests/CompactTest/mockStore.ts b/packages/inula/tests/CompactTest/mockStore.ts new file mode 100644 index 00000000..d17213c0 --- /dev/null +++ b/packages/inula/tests/CompactTest/mockStore.ts @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2023 Huawei Technologies Co.,Ltd. + * + * openInula is licensed under Mulan PSL v2. + * You can use this software according to the terms and conditions of the Mulan PSL v2. + * You may obtain a copy of Mulan PSL v2 at: + * + * http://license.coscl.org.cn/MulanPSL2 + * + * THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, + * EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, + * MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE. + * See the Mulan PSL v2 for more details. + */ + +type Listener = () => void; + +interface MockStore { + getValue(): T; + setValue(newValue: T): void; + subscribe(listener: Listener): () => void; + getListenerCount(): number; + enableSnapshotError(): void; + disableSnapshotError(): void; + enableSubscribeError(): void; + disableSubscribeError(): void; + updateProperty(key: K, newValue: T[K]): void; +} + +export function createMockStore(initialValue: T): MockStore { + let value = initialValue; + const listeners = new Set(); + let shouldThrowOnSnapshot = false; + let shouldThrowOnSubscribe = false; + + const store: MockStore = { + getValue() { + if (shouldThrowOnSnapshot) { + throw new Error('getSnapshot error'); + } + return value; + }, + + setValue(newValue: T) { + // For objects: check reference first, then deep comparison + // For primitives: simple comparison + let shouldUpdate = false; + + if (typeof value === 'object' && value !== null && typeof newValue === 'object' && newValue !== null) { + // For objects: update if different reference OR different content + shouldUpdate = value !== newValue || JSON.stringify(value) !== JSON.stringify(newValue); + if (shouldUpdate) { + value = { ...newValue as any }; + } + } else { + // For primitives: simple comparison + shouldUpdate = value !== newValue; + if (shouldUpdate) { + value = newValue; + } + } + + if (shouldUpdate) { + listeners.forEach(listener => listener()); + } + }, + + updateProperty(key: K, newValue: T[K]) { + if (typeof value === 'object' && value !== null) { + const currentValue = (value as any)[key]; + if (currentValue !== newValue) { + value = { ...(value as any), [key]: newValue }; + listeners.forEach(listener => listener()); + } + } + }, + + subscribe(listener: Listener) { + if (shouldThrowOnSubscribe) { + throw new Error('subscribe error'); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + getListenerCount() { + return listeners.size; + }, + + // Test utilities + enableSnapshotError() { + shouldThrowOnSnapshot = true; + }, + + disableSnapshotError() { + shouldThrowOnSnapshot = false; + }, + + enableSubscribeError() { + shouldThrowOnSubscribe = true; + }, + + disableSubscribeError() { + shouldThrowOnSubscribe = false; + } + }; + + return store; +} \ No newline at end of file From af37c4e9a539035f4b6505cb16940a636a55f337 Mon Sep 17 00:00:00 2001 From: HoikanChan Date: Mon, 8 Sep 2025 20:18:35 +0800 Subject: [PATCH 2/2] fix: useSyncExternalStore ut --- packages/inula/src/compat/UseSyncExternalStoreHook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/inula/src/compat/UseSyncExternalStoreHook.ts b/packages/inula/src/compat/UseSyncExternalStoreHook.ts index 4f8b00a4..e2af7a2f 100644 --- a/packages/inula/src/compat/UseSyncExternalStoreHook.ts +++ b/packages/inula/src/compat/UseSyncExternalStoreHook.ts @@ -94,7 +94,7 @@ type SelectionInstance = // 确保返回的类型既是原始类型 T,又确实是个函数 function isFunction(value: T): value is T & ((...args: any[]) => any) { - return typeof value !== 'function'; + return typeof value === 'function'; } // 与useSyncExternalStore相同,但支持选择器和相等性判断参数