Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions packages/inula/src/compat/UseSyncExternalStoreHook.ts
Original file line number Diff line number Diff line change
@@ -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<T> = { value: T; getSnapshot: () => T }

export function useSyncExternalStore<T>(
subscribe: (onStoreChange: StoreChangeListener) => Unsubscribe,
getSnapshot: () => T,
): T {
// 获取当前Store快照
const value = getSnapshot();

Comment on lines +29 to +35
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Align base hook with React API: support optional getServerSnapshot.

Adding the optional third param improves compat and prevents SSR/client divergence for non-selector usage.

-export function useSyncExternalStore<T>(
-  subscribe: (onStoreChange: StoreChangeListener) => Unsubscribe,
-  getSnapshot: () => T,
-): T {
-  // 获取当前Store快照
-  const value = getSnapshot();
+export function useSyncExternalStore<T>(
+  subscribe: (onStoreChange: StoreChangeListener) => Unsubscribe,
+  getSnapshot: () => T,
+  getServerSnapshot?: () => T,
+): T {
+  // 获取当前Store快照(SSR 时优先使用 getServerSnapshot)
+  const readSnapshot =
+    typeof window === 'undefined' && typeof getServerSnapshot === 'function'
+      ? getServerSnapshot
+      : getSnapshot;
+  const value = readSnapshot();
🤖 Prompt for AI Agents
In packages/inula/src/compat/UseSyncExternalStoreHook.ts around lines 29-35, the
exported useSyncExternalStore hook signature and implementation lack the
optional third parameter getServerSnapshot required by the React API; update the
function signature to accept getServerSnapshot?: () => T, update internal usage
to call getServerSnapshot when available for server snapshot resolution (falling
back to getSnapshot), and update types so the third arg is optional and returned
value still typed as T; ensure existing call sites compile with the new optional
parameter.

// 用于强制重新渲染和存储store引用,非普通state
// 需要强制更新时调用 `forceUpdate({inst})`,由于是新的对象引入,会导致组件强制更新
const [{ store }, forceUpdate] = useState<{ store: Store<T> }>({
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<T>(store: Store<T>) {
const latestGetSnapshot = store.getSnapshot;
const prevValue = store.value;
try {
const nextValue = latestGetSnapshot();
return !isSame(prevValue, nextValue);
} catch (error) {
return true;
}
}

// 用于追踪已渲染快照的实例类型
type SelectionInstance<Selection> =
| { hasValue: true; value: Selection }
| { hasValue: false; value: null };

// 确保返回的类型既是原始类型 T,又确实是个函数
function isFunction<T>(value: T): value is T & ((...args: any[]) => any) {
return typeof value === 'function';
}

// 与useSyncExternalStore相同,但支持选择器和相等性判断参数
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
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<SelectionInstance<Selection>>({
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]);
Comment on lines +166 to +167
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Use getServerSnapshot for SSR to avoid hydration mismatches.

getServerSnapshot is accepted but not used. Wire it in and include as a dependency.

-    return () => memoizedSelectorFn(getSnapshot());
-  }, [getSnapshot, selector, isEqual]);
+    return () =>
+      memoizedSelectorFn(
+        (typeof window === 'undefined' && getServerSnapshot
+          ? getServerSnapshot
+          : getSnapshot)()
+      );
+  }, [getSnapshot, getServerSnapshot, selector, isEqual]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return () => memoizedSelectorFn(getSnapshot());
}, [getSnapshot, selector, isEqual]);
return () =>
memoizedSelectorFn(
(typeof window === 'undefined' && getServerSnapshot
? getServerSnapshot
: getSnapshot)()
);
}, [getSnapshot, getServerSnapshot, selector, isEqual]);
🤖 Prompt for AI Agents
In packages/inula/src/compat/UseSyncExternalStoreHook.ts around lines 166-167,
the getServerSnapshot argument is accepted but not used; update the effect to
use getServerSnapshot() when available (e.g., call
memoizedSelectorFn(getServerSnapshot()) instead of always using getSnapshot())
for SSR initial reads and any server snapshot reads, and add getServerSnapshot
to the hook's dependency array so React re-runs the effect when it changes
(final deps: [getSnapshot, getServerSnapshot, selector, isEqual]).


Comment on lines +114 to +168
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Sanity-check: identity stability of selectedValue across equal updates.

After fixing isFunction, the selector path should now retain referential identity when isEqual says equal. Please confirm tests assert identity stability (Object.is) across updates.


🏁 Script executed:

#!/bin/bash
# Verify selector identity stability when isEqual returns true.
rg -nC3 "useSyncExternalStoreWithSelector\(" packages | cat
rg -n "Object\.is\(|toBe\(.*same.*reference|toBe\(.*selectedValue" packages/inula/tests -g '!**/node_modules/**' || true

Length of output: 5466


Add identity-stability test for selector

Tests don’t currently verify that selectedValue preserves referential equality when isEqual returns true (no Object.is or toBe assertions found). Add a test in UseSyncExternalStore.test.tsx that updates the store with an equal value and asserts the same reference is returned.

const selectedValue = useSyncExternalStore(subscribe, snapshotWithSelector);

// 更新实例状态
useEffect(() => {
instRef.current.hasValue = true;
instRef.current.value = selectedValue;
}, [selectedValue]);

return selectedValue;
}
5 changes: 5 additions & 0 deletions packages/inula/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -93,6 +94,8 @@ const Inula = {
useReducer,
useRef,
useState,
useSyncExternalStore,
useSyncExternalStoreWithSelector,
createElement,
cloneElement,
isValidElement,
Expand Down Expand Up @@ -146,6 +149,8 @@ export {
useReducer,
useRef,
useState,
useSyncExternalStore,
useSyncExternalStoreWithSelector,
createElement,
cloneElement,
isValidElement,
Expand Down
Loading
Loading