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,