diff --git a/.changeset/flat-ties-spend.md b/.changeset/flat-ties-spend.md
new file mode 100644
index 0000000000..eb09c330f2
--- /dev/null
+++ b/.changeset/flat-ties-spend.md
@@ -0,0 +1,5 @@
+---
+"@lynx-js/react": patch
+---
+
+fix: When hydrate list node with `SerializedSnapshotInstance` and `BackgroundSnapshotInstance`, using `item-key` from values to diff to make sure we can get correct diff result.
diff --git a/packages/react/runtime/__test__/hydrate.test.jsx b/packages/react/runtime/__test__/hydrate.test.jsx
index 0f77fb1bff..f28437ad24 100644
--- a/packages/react/runtime/__test__/hydrate.test.jsx
+++ b/packages/react/runtime/__test__/hydrate.test.jsx
@@ -7,10 +7,32 @@ import {
BackgroundSnapshotInstance,
hydrate,
} from '../src/snapshot';
+import { SnapshotOperationParams } from '../src/lifecycle/patch/snapshotPatch';
+import { __pendingListUpdates } from '../src/list/pendingListUpdates';
+import { getItemKeyOf } from '../src/renderToOpcodes/hydrate';
const HOLE = null;
+export function formatSnapshotPatch(patch) {
+ const out = [];
+ for (let i = 0; i < patch.length;) {
+ const op = patch[i];
+ const meta = SnapshotOperationParams[op];
+ if (!meta) {
+ out.push(`UnknownOp(${String(op)})`);
+ i += 1;
+ continue;
+ }
+ const argc = meta.params.length;
+ const args = patch.slice(i + 1, i + 1 + argc);
+ out.push(`${meta.name}(${args.map(a => JSON.stringify(a)).join(', ')})`);
+ i += 1 + argc;
+ }
+ return out;
+}
+
beforeEach(() => {
+ __pendingListUpdates.clearAttachedLists();
backgroundSnapshotInstanceManager.clear();
backgroundSnapshotInstanceManager.nextId = 0;
snapshotInstanceManager.clear();
@@ -325,3 +347,190 @@ describe('dual-runtime hydrate - with slot (multi-children)', () => {
`);
});
});
+
+describe('dual-runtime hydrate - with list', () => {
+ const listHolder = __SNAPSHOT__(
+
+ {HOLE}
+
,
+ );
+ const listItem = __SNAPSHOT__(
+
+ Item
+ ,
+ );
+
+ it('should works - list', () => {
+ const mtsList = new SnapshotInstance(listHolder);
+ mtsList.ensureElements();
+ const listRef = mtsList.__elements[0];
+
+ const mtsListItem0 = new SnapshotInstance(listItem);
+ mtsListItem0.setAttribute(0, { 'item-key': 'mts-list-item-0' });
+ const mtsListItem1 = new SnapshotInstance(listItem);
+ mtsListItem1.setAttribute(0, { 'item-key': 'mts-list-item-1' });
+ const mtsListItem2 = new SnapshotInstance(listItem);
+ mtsListItem2.setAttribute(0, { 'item-key': 'mts-list-item-2' });
+ mtsList.insertBefore(mtsListItem0);
+ mtsList.insertBefore(mtsListItem1);
+ mtsList.insertBefore(mtsListItem2);
+ __pendingListUpdates.flush();
+ expect(listRef).toMatchInlineSnapshot(`
+
+ `);
+ const getItemKeyFromValues = (values) => {
+ for (let index = 0; index < values?.length; index++) {
+ const value = values[index];
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ if ('item-key' in value) {
+ return value['item-key'] ?? undefined;
+ }
+ }
+ }
+ return undefined;
+ };
+ mtsList.childNodes.forEach((node, index) => {
+ const itemKey = getItemKeyFromValues(node.__values);
+ expect(itemKey).toBeTypeOf('string');
+ expect(itemKey).toBe(`mts-list-item-${index}`);
+ });
+
+ const btsList = new BackgroundSnapshotInstance(listHolder);
+ const btsListItem0 = new BackgroundSnapshotInstance(listItem);
+ btsListItem0.setAttribute(0, { 'item-key': 'bts-list-item-0' });
+ const btsListItem1 = new BackgroundSnapshotInstance(listItem);
+ btsListItem1.setAttribute(0, { 'item-key': 'bts-list-item-1' });
+ const btsListItem2 = new BackgroundSnapshotInstance(listItem);
+ btsListItem2.setAttribute(0, { 'item-key': 'bts-list-item-2' });
+ btsList.insertBefore(btsListItem0);
+ btsList.insertBefore(btsListItem1);
+ btsList.insertBefore(btsListItem2);
+
+ btsList.childNodes.forEach((node, index) => {
+ const itemKey = getItemKeyFromValues(node.__values);
+ expect(itemKey).toBeTypeOf('string');
+ expect(itemKey).toBe(`bts-list-item-${index}`);
+ });
+ const patches = hydrate(JSON.parse(JSON.stringify(mtsList)), btsList);
+ expect(patches).toMatchInlineSnapshot(`
+ [
+ 2,
+ -1,
+ -2,
+ 2,
+ -1,
+ -3,
+ 2,
+ -1,
+ -4,
+ 0,
+ "__snapshot_a94a8_test_10",
+ 2,
+ 4,
+ 2,
+ [
+ {
+ "item-key": "bts-list-item-0",
+ },
+ ],
+ 1,
+ -1,
+ 2,
+ undefined,
+ 0,
+ "__snapshot_a94a8_test_10",
+ 3,
+ 4,
+ 3,
+ [
+ {
+ "item-key": "bts-list-item-1",
+ },
+ ],
+ 1,
+ -1,
+ 3,
+ undefined,
+ 0,
+ "__snapshot_a94a8_test_10",
+ 4,
+ 4,
+ 4,
+ [
+ {
+ "item-key": "bts-list-item-2",
+ },
+ ],
+ 1,
+ -1,
+ 4,
+ undefined,
+ ]
+ `);
+ expect(formatSnapshotPatch(patches)).toMatchInlineSnapshot(`
+ [
+ "RemoveChild(-1, -2)",
+ "RemoveChild(-1, -3)",
+ "RemoveChild(-1, -4)",
+ "CreateElement("__snapshot_a94a8_test_10", 2)",
+ "SetAttributes(2, [{"item-key":"bts-list-item-0"}])",
+ "InsertBefore(-1, 2, )",
+ "CreateElement("__snapshot_a94a8_test_10", 3)",
+ "SetAttributes(3, [{"item-key":"bts-list-item-1"}])",
+ "InsertBefore(-1, 3, )",
+ "CreateElement("__snapshot_a94a8_test_10", 4)",
+ "SetAttributes(4, [{"item-key":"bts-list-item-2"}])",
+ "InsertBefore(-1, 4, )",
+ ]
+ `);
+ });
+});
+
+describe('renderToOpcodes hydrate - getItemKeyOf', () => {
+ it('should get item-key from __listItemPlatformInfo', () => {
+ expect(getItemKeyOf({ __listItemPlatformInfo: { 'item-key': 'k' } }, true)).toBe('k');
+ });
+
+ it('should return undefined when __listItemPlatformInfo has no item-key', () => {
+ expect(getItemKeyOf({ __listItemPlatformInfo: {} }, true)).toBe(undefined);
+ });
+
+ it('should get item-key from values when before node', () => {
+ expect(getItemKeyOf({ values: [null, 1, { 'item-key': 'k2' }] }, true)).toBe('k2');
+ });
+
+ it('should return undefined when values includes item-key undefined', () => {
+ expect(getItemKeyOf({ values: [{ 'item-key': undefined }] }, true)).toBe(undefined);
+ });
+
+ it('should get item-key from __values when after node', () => {
+ expect(getItemKeyOf({ __values: [{ 'item-key': 'k3' }] }, false)).toBe('k3');
+ });
+});
diff --git a/packages/react/runtime/src/renderToOpcodes/hydrate.ts b/packages/react/runtime/src/renderToOpcodes/hydrate.ts
index cee54eaabe..90fcf8c53f 100644
--- a/packages/react/runtime/src/renderToOpcodes/hydrate.ts
+++ b/packages/react/runtime/src/renderToOpcodes/hydrate.ts
@@ -11,8 +11,6 @@ import { unref } from '../snapshot/ref.js';
import type { SnapshotInstance } from '../snapshot/snapshot.js';
import { isEmptyObject } from '../utils.js';
-const UNREACHABLE_ITEM_KEY_NOT_FOUND = 'UNREACHABLE_ITEM_KEY_NOT_FOUND';
-
export interface DiffResult {
$$diff: true;
// insert No.j to new
@@ -25,7 +23,34 @@ export interface DiffResult {
export interface Typed {
type: string;
+ // from snapshotInstance
__listItemPlatformInfo?: PlatformInfo;
+ // from serializeSnapshotInstance
+ values?: any[] | undefined;
+ // from backgroundSnapshotInstance
+ __values?: any[] | undefined;
+}
+
+export function getItemKeyOf(
+ node: Pick,
+ isBeforeNode: boolean,
+): string | undefined {
+ if (node?.__listItemPlatformInfo) {
+ // if diff list children in mts, the node has __listItemPlatformInfo, so we get item-key from it
+ return node?.__listItemPlatformInfo?.['item-key'] ?? undefined;
+ }
+ // if the node is the before node in diff, we get item-key from values which is passed from serializeSnapshotInstance
+ const valueArray = (isBeforeNode ? node?.values : node?.__values) as unknown[];
+ for (let index = 0; index < valueArray?.length; index++) {
+ const value = valueArray[index];
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ const obj = value as Record;
+ if ('item-key' in obj) {
+ return obj['item-key'] as string ?? undefined;
+ }
+ }
+ }
+ return undefined;
}
export function isEmptyDiffResult(diffResult: DiffResult): boolean {
@@ -39,7 +64,7 @@ export function diffArrayLepus(
after: B[],
isSameType: (a: A, b: B) => boolean,
onDiffChildren: (a: A, b: B, oldIndex: number, newIndex: number) => void,
- isListHasItemKey: boolean,
+ isListItem: boolean,
): DiffResult {
let lastPlacedIndex = 0;
const result: DiffResult = {
@@ -52,17 +77,13 @@ export function diffArrayLepus(
for (let i = 0; i < before.length; i++) {
const node = before[i]!;
- const key = isListHasItemKey
- ? node.__listItemPlatformInfo?.['item-key'] ?? UNREACHABLE_ITEM_KEY_NOT_FOUND
- : node.type;
+ const key = isListItem ? (getItemKeyOf(node, true) ?? node.type) : node.type;
(beforeMap[key] ??= new Set()).add([node, i]);
}
for (let i = 0; i < after.length; i++) {
const afterNode = after[i]!;
- const key = isListHasItemKey
- ? afterNode.__listItemPlatformInfo?.['item-key'] ?? UNREACHABLE_ITEM_KEY_NOT_FOUND
- : afterNode.type;
+ const key = isListItem ? (getItemKeyOf(afterNode, false) ?? afterNode.type) : afterNode.type;
const beforeNodes = beforeMap[key];
let beforeNode: [A, number];
diff --git a/packages/react/runtime/src/snapshot/backgroundSnapshot.ts b/packages/react/runtime/src/snapshot/backgroundSnapshot.ts
index 18c56ed0f2..99f088683e 100644
--- a/packages/react/runtime/src/snapshot/backgroundSnapshot.ts
+++ b/packages/react/runtime/src/snapshot/backgroundSnapshot.ts
@@ -596,8 +596,7 @@ export function hydrate(
(a, b) => {
helper(a, b);
},
- // Should be `false` in hydrate as SerializedSnapshotInstance has no item-key
- false,
+ type === DynamicPartType.ListChildren,
);
diffArrayAction(
beforeChildNodes,