Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe('ElementTemplate alog helpers', () => {
11,
1,
12,
[12],
])).toEqual([
{
op: 'createTemplate',
Expand Down Expand Up @@ -62,6 +63,7 @@ describe('ElementTemplate alog helpers', () => {
targetId: 11,
elementSlotIndex: 1,
childId: 12,
removedSubtreeHandleIds: [12],
},
]);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Object {
-3,
0,
-1,
Array [
-1,
],
1,
5,
"_et_04991_test_3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ Object {
-2,
0,
-1,
Array [
-1,
],
],
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,8 @@ Object {
-1,
0,
-3,
Array [
-3,
],
],
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ Object {
-1,
0,
-2,
Array [
-2,
],
],
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ Object {
1,
0,
-2,
Array [
-2,
],
4,
1,
0,
-3,
Array [
-3,
],
],
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ describe('hydrate', () => {
root.instanceId,
0,
stale.instanceId,
[stale.instanceId],
]);
expect(root.elementSlots[0]).toEqual([]);
});
Expand Down Expand Up @@ -266,6 +267,7 @@ describe('hydrate', () => {
root.instanceId,
0,
oldAId,
[oldAId],
ElementTemplateUpdateOps.createTemplate,
newA.instanceId,
'new-a',
Expand Down Expand Up @@ -380,10 +382,12 @@ describe('hydrate', () => {
root.instanceId,
0,
-2,
[-2],
4,
root.instanceId,
0,
-3,
[-3],
]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe('BackgroundElementTemplateInstance', () => {
parent.instanceId,
0,
child.instanceId,
[child.instanceId],
]);
});

Expand Down Expand Up @@ -415,6 +416,11 @@ describe('BackgroundElementTemplateInstance', () => {
slot.setAttribute('id', 0);
parent.appendChild(slot);
const child = new BackgroundElementTemplateInstance('text');
const childSlot = new BackgroundElementTemplateSlot();
childSlot.setAttribute('id', 0);
const grandchild = new BackgroundElementTemplateInstance('text');
child.appendChild(childSlot);
childSlot.appendChild(grandchild);
slot.appendChild(child);

GlobalCommitContext.ops = [];
Expand All @@ -426,6 +432,7 @@ describe('BackgroundElementTemplateInstance', () => {
parent.instanceId,
0,
child.instanceId,
[child.instanceId, grandchild.instanceId],
]);
expect(GlobalCommitContext.nonPayload.removedSubtrees).toEqual([child]);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ describe('Compiled background Preact updates', () => {
host.instanceId,
SLOT_ID,
removed.instanceId,
[removed.instanceId],
]);
envManager.switchToBackground();
expect(backgroundElementTemplateInstanceManager.get(removed.instanceId)).toBe(removed);
Expand Down Expand Up @@ -217,6 +218,7 @@ describe('Compiled background Preact updates', () => {
host.instanceId,
SLOT_ID,
removed.instanceId,
[removed.instanceId],
]);
envManager.switchToBackground();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -353,26 +353,48 @@ describe('ElementTemplate patch stream (apply)', () => {
it('reports missing child handle on removeNode', () => {
envManager.switchToMainThread();
const targetRef = { __isNativeRef: true, id: 'target' } as unknown as ElementRef;
const descendantRef = { __isNativeRef: true, id: 'descendant' } as unknown as ElementRef;
ElementTemplateRegistry.set(1, targetRef);
ElementTemplateRegistry.set(12, descendantRef);

applyElementTemplateUpdateCommands([ElementTemplateUpdateOps.removeNode, 1, 0, 999]);
applyElementTemplateUpdateCommands([ElementTemplateUpdateOps.removeNode, 1, 0, 999, [12]]);

expect(mockRemoveNodeFromElementTemplate.mock.calls).toHaveLength(0);
expect(ElementTemplateRegistry.has(12)).toBe(true);
const reportError = (globalThis.lynx as unknown as LynxWithReportErrorMock).reportError;
expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toContain('child handle 999 not found');
resetReportedErrors();
});

it('resolves insert/remove references from registry', () => {
it('reports missing target handle on removeNode without deleting subtree registry entries', () => {
envManager.switchToMainThread();
const childRef = { __isNativeRef: true, id: 'child' } as unknown as ElementRef;
const descendantRef = { __isNativeRef: true, id: 'descendant' } as unknown as ElementRef;
ElementTemplateRegistry.set(11, childRef);
ElementTemplateRegistry.set(12, descendantRef);

applyElementTemplateUpdateCommands([ElementTemplateUpdateOps.removeNode, 999, 0, 11, [11, 12]]);

expect(mockRemoveNodeFromElementTemplate.mock.calls).toHaveLength(0);
expect(ElementTemplateRegistry.has(11)).toBe(true);
expect(ElementTemplateRegistry.has(12)).toBe(true);
const reportError = (globalThis.lynx as unknown as LynxWithReportErrorMock).reportError;
expect(String(reportError.mock.calls[0]?.[0]?.message ?? '')).toContain('target handle 999 not found');
resetReportedErrors();
});

it('removes registry entries for the detached subtree after native remove succeeds', () => {
envManager.switchToMainThread();
ElementTemplateRegistry.clear();

const targetRef = { __isNativeRef: true, id: 'target' } as unknown as ElementRef;
const beforeRef = { __isNativeRef: true, id: 'before' } as unknown as ElementRef;
const childRef = { __isNativeRef: true, id: 'child' } as unknown as ElementRef;
const descendantRef = { __isNativeRef: true, id: 'descendant' } as unknown as ElementRef;
ElementTemplateRegistry.set(1, targetRef);
ElementTemplateRegistry.set(10, beforeRef);
ElementTemplateRegistry.set(11, childRef);
ElementTemplateRegistry.set(12, descendantRef);

const stream: ElementTemplateUpdateCommandStream = [
ElementTemplateUpdateOps.insertNode,
Expand All @@ -384,6 +406,7 @@ describe('ElementTemplate patch stream (apply)', () => {
1,
0,
11,
[11, 12],
];

applyElementTemplateUpdateCommands(stream);
Expand All @@ -393,6 +416,10 @@ describe('ElementTemplate patch stream (apply)', () => {
expect(mockInsertNodeToElementTemplate.mock.calls[0]?.[3]).toBe(beforeRef);
expect(mockRemoveNodeFromElementTemplate.mock.calls[0]?.[0]).toBe(targetRef);
expect(mockRemoveNodeFromElementTemplate.mock.calls[0]?.[2]).toBe(childRef);
expect(ElementTemplateRegistry.has(1)).toBe(true);
expect(ElementTemplateRegistry.has(10)).toBe(true);
expect(ElementTemplateRegistry.has(11)).toBe(false);
expect(ElementTemplateRegistry.has(12)).toBe(false);
});

it('creates builtin raw-text template from attributeSlots', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ describe('element-template update runner', () => {
2,
3,
'next',
ElementTemplateUpdateOps.removeNode,
4,
5,
6,
[6, 7],
];

expect(formatUpdateStream(stream)).toEqual([
Expand All @@ -34,6 +39,13 @@ describe('element-template update runner', () => {
attrSlotIndex: 3,
value: 'next',
},
{
type: 'removeNode',
id: 4,
elementSlotIndex: 5,
child: 6,
removedSubtreeHandleIds: [6, 7],
},
]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type FormattedUpdateEntry =
id: number;
elementSlotIndex: number;
child: unknown;
removedSubtreeHandleIds: unknown;
};

export interface UpdateRunOptions {
Expand Down Expand Up @@ -110,6 +111,7 @@ export function formatUpdateStream(stream: ElementTemplateUpdateCommandStream):
id: stream[index++] as number,
elementSlotIndex: stream[index++] as number,
child: stream[index++] as unknown,
removedSubtreeHandleIds: stream[index++] as unknown,
});
}
return formatted;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export class BackgroundElementTemplateInstance {
parent.instanceId,
slotId,
child.instanceId,
collectElementTemplateSubtreeHandleIds(child),
);
// The removed JS object graph may outlive the detach until GC, so keep
// it pending and tear it down on the Snapshot-aligned delayed boundary.
Expand Down
2 changes: 2 additions & 0 deletions packages/react/runtime/src/element-template/debug/alog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type FormattedElementTemplateUpdateCommand =
targetId: number;
elementSlotIndex: number;
childId: number;
removedSubtreeHandleIds: number[];
}
| {
op: 'unknown';
Expand Down Expand Up @@ -91,6 +92,7 @@ export function formatElementTemplateUpdateCommands(
targetId: stream[index++] as number,
elementSlotIndex: stream[index++] as number,
childId: stream[index++] as number,
removedSubtreeHandleIds: stream[index++] as number[],
});
break;

Expand Down
49 changes: 44 additions & 5 deletions packages/react/runtime/src/element-template/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

import { ElementTemplateUpdateOps } from './opcodes.js';

export type SerializableValue =
| string
| number
Expand All @@ -20,11 +22,48 @@ export interface SerializedElementTemplate {
uid: number | string;
}

// Stage 7 target protocol: flat command stream over create/setAttribute/insert/remove.
// The stream intentionally stays opaque at this layer until runtime producer/consumer land.
export type ElementTemplateUpdateCommandStream = (
number | string | null | SerializableValue | SerializableValue[] | unknown[]
)[];
export type CreateTemplateCommand = [
typeof ElementTemplateUpdateOps.createTemplate,
handleId: number,
templateKey: string,
bundleUrl: string | null | undefined,
attributeSlots: SerializableValue[] | null | undefined,
elementSlots: number[][] | null | undefined,
];

export type SetAttributeCommand = [
typeof ElementTemplateUpdateOps.setAttribute,
targetHandleId: number,
attrSlotIndex: number,
value: SerializableValue | null,
];

export type InsertNodeCommand = [
typeof ElementTemplateUpdateOps.insertNode,
targetHandleId: number,
elementSlotIndex: number,
childHandleId: number,
referenceHandleId: number,
];

export type RemoveNodeCommand = [
typeof ElementTemplateUpdateOps.removeNode,
targetHandleId: number,
elementSlotIndex: number,
childHandleId: number,
removedSubtreeHandleIds: number[],
];

export type ElementTemplateUpdateCommand =
| CreateTemplateCommand
| SetAttributeCommand
| InsertNodeCommand
| RemoveNodeCommand;

// Commands are transported as a flat stream to match the native update payload.
// Tuple aliases above define each opcode's shape; this item union preserves the
// existing flat buffer ergonomics while making command contracts explicit.
export type ElementTemplateUpdateCommandStream = ElementTemplateUpdateCommand[number][];

export interface ElementTemplateFlushOptions {
// triggerLayout?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,18 @@ export function applyElementTemplateUpdateCommands(
const targetId = stream[i++] as number;
const elementSlotIndex = stream[i++] as number;
const childId = stream[i++] as number;
const removedSubtreeHandleIds = stream[i++] as number[];
Comment thread
Yradex marked this conversation as resolved.
const nativeRef = resolveHandle(targetId, 'target');
const childRef = resolveHandle(childId, 'child');
if (!nativeRef || !childRef) {
continue;
}
__RemoveNodeFromElementTemplate(nativeRef, elementSlotIndex, childRef);
// The native API only detaches from the slot. Releasing ET runtime's
// strong refs after a successful detach lets JS GC reclaim the subtree.
for (const handleId of removedSubtreeHandleIds) {
ElementTemplateRegistry.delete(handleId);
}
break;
}

Expand Down
Loading