diff --git a/.changeset/quiet-templates-smile.md b/.changeset/quiet-templates-smile.md new file mode 100644 index 0000000000..3eda6bcee4 --- /dev/null +++ b/.changeset/quiet-templates-smile.md @@ -0,0 +1,5 @@ +--- + +--- + +No package release is required because this change only adds Element Template runtime test coverage and aligns hydrate nullish slot handling with existing Snapshot behavior, without changing public APIs, package exports, or release-facing defaults. diff --git a/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts index 83e273fbde..e5e4f4abcb 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/background/hydrate.test.ts @@ -1,5 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GlobalCommitContext } from '../../../../src/element-template/background/commit-context.js'; +import { + markElementTemplateHydrated, + resetElementTemplateCommitState, +} from '../../../../src/element-template/background/commit-hook.js'; import { hydrate } from '../../../../src/element-template/background/hydrate.js'; import { BackgroundElementTemplateInstance, @@ -7,6 +12,7 @@ import { BUILTIN_RAW_TEXT_TEMPLATE_KEY, } from '../../../../src/element-template/background/instance.js'; import { backgroundElementTemplateInstanceManager } from '../../../../src/element-template/background/manager.js'; +import { ElementTemplateUpdateOps } from '../../../../src/element-template/protocol/opcodes.js'; import type { SerializedElementTemplate } from '../../../../src/element-template/protocol/types.js'; function createHydrationTemplate( @@ -40,6 +46,7 @@ describe('hydrate', () => { beforeEach(() => { backgroundElementTemplateInstanceManager.clear(); backgroundElementTemplateInstanceManager.nextId = 0; + resetElementTemplateCommitState(); vi.clearAllMocks(); (globalThis as { __LYNX_REPORT_ERROR_CALLS?: Error[] }).__LYNX_REPORT_ERROR_CALLS = []; }); @@ -63,6 +70,291 @@ describe('hydrate', () => { expect(root.elementSlots[0]).toEqual([child]); }); + it('patches attribute slots while creating and inserting background-only children', () => { + const root = new BackgroundElementTemplateInstance('root', ['background-root']); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + root.appendChild(slot); + + const existing = new BackgroundElementTemplateInstance('item', ['background-existing']); + slot.appendChild(existing); + + const card = new BackgroundElementTemplateInstance('card', ['background-card']); + const cardSlot = new BackgroundElementTemplateSlot(); + cardSlot.setAttribute('id', 0); + card.appendChild(cardSlot); + const rawText = new BackgroundElementTemplateInstance(BUILTIN_RAW_TEXT_TEMPLATE_KEY, ['NEW']); + cardSlot.appendChild(rawText); + slot.appendChild(card); + + const stream = hydrate( + createHydrationTemplate(root.instanceId, 'root', { + attributeSlots: ['main-root'], + elementSlots: [[ + createHydrationChild(existing.instanceId, 'item', { + attributeSlots: ['main-existing'], + }), + ]], + }), + root, + ); + + expect(stream).toEqual([ + ElementTemplateUpdateOps.setAttribute, + root.instanceId, + 0, + 'background-root', + ElementTemplateUpdateOps.setAttribute, + existing.instanceId, + 0, + 'background-existing', + ElementTemplateUpdateOps.createTemplate, + rawText.instanceId, + BUILTIN_RAW_TEXT_TEMPLATE_KEY, + null, + ['NEW'], + [], + ElementTemplateUpdateOps.createTemplate, + card.instanceId, + 'card', + null, + ['background-card'], + [[rawText.instanceId]], + ElementTemplateUpdateOps.insertNode, + root.instanceId, + 0, + card.instanceId, + 0, + ]); + expect(root.elementSlots[0]).toEqual([existing, card]); + expect(card.elementSlots[0]).toEqual([rawText]); + }); + + it('removes serialized children that are missing from the background slot', () => { + const root = new BackgroundElementTemplateInstance('root'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + root.appendChild(slot); + + const stale = new BackgroundElementTemplateInstance('stale'); + + const stream = hydrate( + createHydrationTemplate(root.instanceId, 'root', { + elementSlots: [[createHydrationChild(stale.instanceId, 'stale')]], + }), + root, + ); + + expect(stream).toEqual([ + ElementTemplateUpdateOps.removeNode, + root.instanceId, + 0, + stale.instanceId, + ]); + expect(root.elementSlots[0]).toEqual([]); + }); + + it('moves serialized children to match the background slot order', () => { + const root = new BackgroundElementTemplateInstance('root'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + root.appendChild(slot); + + const a = new BackgroundElementTemplateInstance('a'); + const b = new BackgroundElementTemplateInstance('b'); + const c = new BackgroundElementTemplateInstance('c'); + slot.appendChild(b); + slot.appendChild(a); + slot.appendChild(c); + + const stream = hydrate( + createHydrationTemplate(root.instanceId, 'root', { + elementSlots: [[ + createHydrationChild(a.instanceId, 'a'), + createHydrationChild(b.instanceId, 'b'), + createHydrationChild(c.instanceId, 'c'), + ]], + }), + root, + ); + + expect(stream).toEqual([ + ElementTemplateUpdateOps.insertNode, + root.instanceId, + 0, + a.instanceId, + c.instanceId, + ]); + expect(root.elementSlots[0]).toEqual([b, a, c]); + }); + + it('recreates background children when serialized root has no child slots', () => { + const root = new BackgroundElementTemplateInstance('root'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + root.appendChild(slot); + + const child = new BackgroundElementTemplateInstance('child', ['background-child']); + const childSlot = new BackgroundElementTemplateSlot(); + childSlot.setAttribute('id', 0); + child.appendChild(childSlot); + const rawText = new BackgroundElementTemplateInstance(BUILTIN_RAW_TEXT_TEMPLATE_KEY, ['NEW']); + childSlot.appendChild(rawText); + slot.appendChild(child); + + const stream = hydrate( + createHydrationTemplate(root.instanceId, 'root'), + root, + ); + + expect(stream).toEqual([ + ElementTemplateUpdateOps.createTemplate, + rawText.instanceId, + BUILTIN_RAW_TEXT_TEMPLATE_KEY, + null, + ['NEW'], + [], + ElementTemplateUpdateOps.createTemplate, + child.instanceId, + 'child', + null, + ['background-child'], + [[rawText.instanceId]], + ElementTemplateUpdateOps.insertNode, + root.instanceId, + 0, + child.instanceId, + 0, + ]); + expect(root.elementSlots[0]).toEqual([child]); + expect(child.elementSlots[0]).toEqual([rawText]); + }); + + it('diffs multiple dynamic children slots independently during hydrate', () => { + const root = new BackgroundElementTemplateInstance('root'); + const slot0 = new BackgroundElementTemplateSlot(); + slot0.setAttribute('id', 0); + root.appendChild(slot0); + const slot1 = new BackgroundElementTemplateSlot(); + slot1.setAttribute('id', 1); + root.appendChild(slot1); + + const newA = new BackgroundElementTemplateInstance('new-a'); + slot0.appendChild(newA); + + const b0 = new BackgroundElementTemplateInstance('b0'); + const b1 = new BackgroundElementTemplateInstance('b1'); + slot1.appendChild(b1); + slot1.appendChild(b0); + + const oldAId = -11; + const stream = hydrate( + createHydrationTemplate(root.instanceId, 'root', { + elementSlots: [ + [createHydrationChild(oldAId, 'old-a')], + [ + createHydrationChild(b0.instanceId, 'b0'), + createHydrationChild(b1.instanceId, 'b1'), + ], + ], + }), + root, + ); + + expect(stream).toEqual([ + ElementTemplateUpdateOps.removeNode, + root.instanceId, + 0, + oldAId, + ElementTemplateUpdateOps.createTemplate, + newA.instanceId, + 'new-a', + null, + [], + [], + ElementTemplateUpdateOps.insertNode, + root.instanceId, + 0, + newA.instanceId, + 0, + ElementTemplateUpdateOps.insertNode, + root.instanceId, + 1, + b0.instanceId, + 0, + ]); + expect(root.elementSlots[0]).toEqual([newA]); + expect(root.elementSlots[1]).toEqual([b1, b0]); + }); + + it('does not match same-type children across element slot indexes', () => { + const root = new BackgroundElementTemplateInstance('root'); + const slot0 = new BackgroundElementTemplateSlot(); + slot0.setAttribute('id', 0); + root.appendChild(slot0); + const slot1 = new BackgroundElementTemplateSlot(); + slot1.setAttribute('id', 1); + root.appendChild(slot1); + + const slot0Item = new BackgroundElementTemplateInstance('item', ['B']); + const slot1Item = new BackgroundElementTemplateInstance('item', ['A']); + slot0.appendChild(slot0Item); + slot1.appendChild(slot1Item); + + const stream = hydrate( + createHydrationTemplate(root.instanceId, 'root', { + elementSlots: [ + [createHydrationChild(slot0Item.instanceId, 'item', { attributeSlots: ['A'] })], + [createHydrationChild(slot1Item.instanceId, 'item', { attributeSlots: ['B'] })], + ], + }), + root, + ); + + expect(stream).toEqual([ + ElementTemplateUpdateOps.setAttribute, + slot0Item.instanceId, + 0, + 'B', + ElementTemplateUpdateOps.setAttribute, + slot1Item.instanceId, + 0, + 'A', + ]); + expect(root.elementSlots[0]).toEqual([slot0Item]); + expect(root.elementSlots[1]).toEqual([slot1Item]); + }); + + it('keeps hydrated handles for later background attribute updates', () => { + const root = new BackgroundElementTemplateInstance('root'); + const slot = new BackgroundElementTemplateSlot(); + slot.setAttribute('id', 0); + root.appendChild(slot); + const child = new BackgroundElementTemplateInstance('child', ['before']); + slot.appendChild(child); + + const childHandleId = -2; + hydrate( + createHydrationTemplate(root.instanceId, 'root', { + elementSlots: [[createHydrationChild(childHandleId, 'child', { attributeSlots: ['before'] })]], + }), + root, + ); + + expect(backgroundElementTemplateInstanceManager.get(childHandleId)).toBe(child); + + markElementTemplateHydrated(); + GlobalCommitContext.ops = []; + child.setAttribute('attributeSlots', ['after']); + + expect(GlobalCommitContext.ops).toEqual([ + ElementTemplateUpdateOps.setAttribute, + childHandleId, + 0, + 'after', + ]); + }); + it('creates missing slots and stringifies raw-text placeholder values', () => { const root = new BackgroundElementTemplateInstance('root'); @@ -200,6 +492,20 @@ describe('hydrate', () => { expect(root.elementSlots).toEqual([]); }); + it('does not patch serialized null when the background attribute slot is missing', () => { + const root = new BackgroundElementTemplateInstance('root'); + + const stream = hydrate( + createHydrationTemplate(root.instanceId, 'root', { + attributeSlots: [null], + }), + root, + ); + + expect(stream).toEqual([]); + expect(root.attributeSlots).toEqual([]); + }); + it('skips sparse background slot indexes when checking trailing slots', () => { const root = new BackgroundElementTemplateInstance('root'); const slot = new BackgroundElementTemplateSlot(); diff --git a/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts index b5fa707550..aab3148391 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/background/instance.test.ts @@ -15,6 +15,7 @@ import { BUILTIN_RAW_TEXT_TEMPLATE_KEY, } from '../../../../src/element-template/background/instance.js'; import { backgroundElementTemplateInstanceManager } from '../../../../src/element-template/background/manager.js'; +import { ElementTemplateUpdateOps } from '../../../../src/element-template/protocol/opcodes.js'; function createTextNode(text: string): BackgroundElementTemplateInstance { return new BackgroundElementTemplateInstance(BUILTIN_RAW_TEXT_TEMPLATE_KEY, [text]); @@ -641,6 +642,23 @@ describe('Background raw-text instance', () => { expect(textNode.data).toBe('world'); }); + it('emits setAttribute when existing raw-text data changes after hydration', () => { + const textNode = createTextNode('old'); + textNode.emitCreate(); + markElementTemplateHydrated(); + GlobalCommitContext.ops = []; + + textNode.data = 'new'; + + expect(textNode.attributeSlots).toEqual(['new']); + expect(GlobalCommitContext.ops).toEqual([ + ElementTemplateUpdateOps.setAttribute, + textNode.instanceId, + 0, + 'new', + ]); + }); + it('stringifies numeric raw-text slot values', () => { const textNode = new BackgroundElementTemplateInstance( BUILTIN_RAW_TEXT_TEMPLATE_KEY, @@ -683,6 +701,23 @@ describe('BackgroundElementTemplateInstance Shadow State', () => { expect(instance.attributeSlots).toEqual(slots); }); + + it('emits null when an existing attribute slot is removed after hydration', () => { + const instance = new BackgroundElementTemplateInstance('view', ['old']); + instance.emitCreate(); + markElementTemplateHydrated(); + GlobalCommitContext.ops = []; + + instance.setAttribute('attributeSlots', []); + + expect(instance.attributeSlots).toEqual([]); + expect(GlobalCommitContext.ops).toEqual([ + ElementTemplateUpdateOps.setAttribute, + instance.instanceId, + 0, + null, + ]); + }); }); describe('BackgroundElementTemplateSlot Children', () => { diff --git a/packages/react/runtime/__test__/element-template/runtime/background/render/preact.test.tsx b/packages/react/runtime/__test__/element-template/runtime/background/render/preact.test.tsx new file mode 100644 index 0000000000..db289dfdd6 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/runtime/background/render/preact.test.tsx @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + markElementTemplateHydrated, + resetElementTemplateCommitState, +} from '../../../../../src/element-template/background/commit-hook.js'; +import { BackgroundElementTemplateInstance } from '../../../../../src/element-template/background/instance.js'; +import { root } from '../../../../../src/element-template/index.js'; +import { __root } from '../../../../../src/element-template/runtime/page/root-instance.js'; +import { ElementTemplateEnvManager } from '../../../test-utils/debug/envManager.js'; + +function shuffle(items: readonly T[]): T[] { + const result = [...items]; + for (let i = result.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j]!, result[i]!]; + } + return result; +} + +function collectRawText(instance: BackgroundElementTemplateInstance): string[] { + const texts: string[] = []; + let child = instance.firstChild; + while (child) { + if (child.type === '__et_builtin_raw_text__') { + texts.push(child.text); + } + texts.push(...collectRawText(child)); + child = child.nextSibling; + } + return texts; +} + +function App({ items }: { items: readonly string[] }): JSX.Element { + return ( + + header + {items.map(item => ( + + {item} + + ))} + + ); +} + +describe('Background Preact render', () => { + const envManager = new ElementTemplateEnvManager(); + + beforeEach(() => { + vi.clearAllMocks(); + resetElementTemplateCommitState(); + envManager.resetEnv('background'); + }); + + it('keeps keyed children order stable when moving the previous head', () => { + root.render(); + markElementTemplateHydrated(); + + root.render(); + + expect(collectRawText(__root as BackgroundElementTemplateInstance)).toEqual(['2', '1', '3', '4']); + }); + + it('keeps keyed children order stable across random shuffles', () => { + const initial = Array.from({ length: 20 }, (_, i) => String(i)); + + root.render(); + markElementTemplateHydrated(); + + for (let i = 0; i < 100; i += 1) { + const next = shuffle(initial); + + root.render(); + + expect( + collectRawText(__root as BackgroundElementTemplateInstance), + `shuffle iteration ${i}: ${JSON.stringify(next)}`, + ).toEqual(next); + } + }); +}); diff --git a/packages/react/runtime/src/element-template/background/hydrate.ts b/packages/react/runtime/src/element-template/background/hydrate.ts index 295b82c604..148c085fb1 100644 --- a/packages/react/runtime/src/element-template/background/hydrate.ts +++ b/packages/react/runtime/src/element-template/background/hydrate.ts @@ -480,6 +480,10 @@ function syncAttributeSlots( if (isDirectOrDeepEqual(beforeValue, afterValue)) { continue; } + if (afterValue === undefined && beforeValue === null) { + // JSON serialization turns undefined array slots into null on the main-thread payload. + continue; + } GlobalCommitContext.ops.push( ElementTemplateUpdateOps.setAttribute, handleId,