Repository: preactjs/preact-devtools
Pull request #417 (2022-08-27 08:32:25)

Sort roots by DOM order


Patch:
diff --git a/src/adapter/10/options.ts b/src/adapter/10/options.ts
index 18d0e00..54b054a 100644
--- a/src/adapter/10/options.ts
+++ b/src/adapter/10/options.ts
@@ -7,6 +7,8 @@ import {
 	getStatefulHooks,
 	getStatefulHookValue,
 	getComponent,
+	isRoot,
+	getActualChildren,
 } from "./bindings";
 import {
 	addDebugValue,
@@ -42,6 +44,7 @@ function trackPrevState(Ctor: ComponentConstructor) {
 export function setupOptionsV10(
 	options: Options,
 	renderer: Renderer,
+	roots: Map<VNode, Node>,
 	config: RendererConfig,
 ) {
 	// Track component state. Only supported in Preact > 10.4.0
@@ -58,6 +61,7 @@ export function setupOptionsV10(
 	// Store (possible) previous hooks so that we don't overwrite them
 	const prevVNodeHook = options.vnode;
 	const prevCommitRoot = o._commit || o.__c;
+	const prevRoot = o._root || o.__;
 	const prevBeforeUnmount = options.unmount;
 	const prevBeforeDiff = o._diff || o.__b;
 	const prevRender = o._render || o.__r;
@@ -164,11 +168,21 @@ export function setupOptionsV10(
 		if (prevAfterDiff) prevAfterDiff(vnode);
 	};
 
+	const userRootToContainer = new Map<VNode, Node>();
 	o._commit = o.__c = (vnode: VNode | null, queue: any[]) => {
 		if (prevCommitRoot) prevCommitRoot(vnode, queue);
 
 		// These cases are already handled by `unmount`
 		if (vnode == null) return;
+		if (isRoot(vnode, config)) {
+			const children = getActualChildren(vnode);
+			if (children.length > 0) {
+				const dom = userRootToContainer.get(children[0] as any);
+				if (dom) {
+					roots.set(vnode, dom);
+				}
+			}
+		}
 
 		const tmpTimings = timings;
 		ownerStack = [];
@@ -176,6 +190,22 @@ export function setupOptionsV10(
 		renderer.onCommit(vnode, owners, tmpTimings, null);
 	};
 
+	o._root = o.__ = (vnode: VNode, parent: Node | null) => {
+		if (parent === null) {
+			userRootToContainer.delete(vnode);
+		} else {
+			// Some islands based frameworks use a virtual container node
+			// instead of an actual DOM node.
+			const treeParent =
+				"Node" in globalThis && parent instanceof Node
+					? parent
+					: (parent as any).parentNode;
+			userRootToContainer.set(vnode, treeParent);
+		}
+
+		if (prevRoot) prevRoot(vnode, parent);
+	};
+
 	options.unmount = vnode => {
 		if (prevBeforeUnmount) prevBeforeUnmount(vnode);
 		if (vnode.type !== null) {
diff --git a/src/adapter/11/options.ts b/src/adapter/11/options.ts
index 45a8b56..1a33792 100644
--- a/src/adapter/11/options.ts
+++ b/src/adapter/11/options.ts
@@ -90,6 +90,7 @@ function trackPrevState(Ctor: any) {
 export function setupOptionsV11(
 	options: OptionsV11,
 	renderer: Renderer,
+	roots: Map<any, Node>,
 	config: RendererConfig,
 	profiler: ProfilerState,
 ) {
diff --git a/src/adapter/adapter/adapter.ts b/src/adapter/adapter/adapter.ts
index 0c589e9..e7106c2 100644
--- a/src/adapter/adapter/adapter.ts
+++ b/src/adapter/adapter/adapter.ts
@@ -9,6 +9,7 @@ import { PortPageHook } from "./port";
 import { PropData } from "../../view/components/sidebar/inspect/parseProps";
 import { PROFILE_RELOAD, STATS_RELOAD } from "../../constants";
 import { ProfilerState } from "./profiler";
+import { RootData, sortRoots } from "../shared/utils";
 
 export type Path = Array<string | number>;
 
@@ -44,7 +45,7 @@ export function createAdapter(
 	profiler: ProfilerState,
 	renderers: Map<number, Renderer>,
 ) {
-	const { listen, send } = port;
+	const { listen, send, listenToPage } = port;
 
 	const forAll = (fn: (renderer: Renderer) => void) => {
 		for (const r of renderers.values()) {
@@ -131,6 +132,17 @@ export function createAdapter(
 		inspect(id);
 	};
 
+	listenToPage("root-order-page", () => {
+		let roots: RootData[] = [];
+		renderers.forEach(r => {
+			const m = r.getRootMappings();
+			roots = roots.concat(m);
+		});
+
+		const sorted = sortRoots(document.body, roots);
+		send("root-order", sorted);
+	});
+
 	listen("update-prop", data => update({ ...data, type: "props" }));
 	listen("update-state", data => update({ ...data, type: "state" }));
 	listen("update-context", data => update({ ...data, type: "context" }));
diff --git a/src/adapter/adapter/port.ts b/src/adapter/adapter/port.ts
index 893196c..9093d1d 100644
--- a/src/adapter/adapter/port.ts
+++ b/src/adapter/adapter/port.ts
@@ -18,6 +18,18 @@ export function listenToDevtools<
 	});
 }
 
+export function listenToPageHook<
+	K extends keyof DevtoolEvents,
+	T extends DevtoolEvents[K]
+>(ctx: Window, type: K, callback: (message: T) => void) {
+	ctx.addEventListener("message", e => {
+		if (e.source === window && e.data.source === PageHookName) {
+			const data = e.data;
+			if (data.type === type) callback(data.data);
+		}
+	});
+}
+
 export function sendToDevtools<K extends keyof DevtoolEvents>(
 	ctx: Window,
 	type: K,
@@ -46,11 +58,16 @@ export interface PortPageHook {
 		type: K,
 		callback: (data: T) => void,
 	) => void;
+	listenToPage: <K extends keyof DevtoolEvents, T extends DevtoolEvents[K]>(
+		type: K,
+		callback: (data: T) => void,
+	) => void;
 }
 
 export function createPortForHook(ctx: Window): PortPageHook {
 	return {
 		send: (type, message) => sendToDevtools(ctx, type, message),
 		listen: (type, callback) => listenToDevtools(ctx, type, callback),
+		listenToPage: (type, callback) => listenToPageHook(ctx, type, callback),
 	};
 }
diff --git a/src/adapter/hook.ts b/src/adapter/hook.ts
index c5f89e3..99fa9da 100644
--- a/src/adapter/hook.ts
+++ b/src/adapter/hook.ts
@@ -58,6 +58,10 @@ export interface DevtoolEvents {
 	refresh: null;
 	disconnect: null;
 	suspend: { id: ID; active: boolean };
+	"root-order-page": null;
+	"root-order": number[];
+	operation: number[];
+	operation_v2: number[];
 }
 export type EmitFn = <K extends keyof DevtoolEvents>(
 	name: K,
@@ -206,6 +210,8 @@ export function createHook(port: PortPageHook): DevtoolsHook {
 			// multiple connected renderers
 			const namespace = Math.floor(Math.random() * 2 ** 32);
 
+			const roots = new Map<any, Node>();
+
 			// currently we only support preact >= 10, later we can add another branch for major === 8
 			if (preactVersionMatch.major == 10) {
 				const supports = {
@@ -230,8 +236,9 @@ export function createHook(port: PortPageHook): DevtoolsHook {
 					filters,
 					idMapper,
 					bindingsV10,
+					roots,
 				);
-				setupOptionsV10(options, renderer, config as any);
+				setupOptionsV10(options, renderer, roots, config as any);
 				return attachRenderer(renderer, supports);
 			} else if (preactVersionMatch.major === 11) {
 				const idMapper = createIdMappingState(
@@ -248,8 +255,9 @@ export function createHook(port: PortPageHook): DevtoolsHook {
 					filters,
 					idMapper,
 					bindingsV11,
+					roots,
 				);
-				setupOptionsV11(options as any, renderer, config, profiler);
+				setupOptionsV11(options as any, renderer, roots, config, profiler);
 				return attachRenderer(renderer, {
 					hooks: true,
 					renderReasons: true,
diff --git a/src/adapter/protocol/events.ts b/src/adapter/protocol/events.ts
index e645635..2b8eaaa 100644
--- a/src/adapter/protocol/events.ts
+++ b/src/adapter/protocol/events.ts
@@ -5,6 +5,7 @@ import { recordProfilerCommit } from "../../view/components/profiler/data/commit
 import { ops2Tree } from "./operations";
 import { applyOperationsV1 } from "./legacy/operationsV1";
 import { OperationInfo, Stats, stats2ops } from "../shared/stats";
+import { DevtoolEvents } from "../hook";
 
 export enum MsgTypes {
 	ADD_ROOT = 1,
@@ -192,7 +193,7 @@ export function applyOperationsV2(store: Store, data: number[]) {
 	}
 }
 
-export function applyEvent(store: Store, type: string, data: any) {
+export function applyEvent(store: Store, type: keyof DevtoolEvents, data: any) {
 	switch (type) {
 		case "attach":
 			if (!store.profiler.isSupported.value) {
@@ -238,5 +239,19 @@ export function applyEvent(store: Store, type: string, data: any) {
 		case "stop-picker":
 			store.isPicking.value = false;
 			break;
+		case "root-order": {
+			const oldRoots = store.roots.value;
+			const newOrder = new Set(data);
+
+			for (let i = 0; i < oldRoots.length; i++) {
+				const id = oldRoots[i];
+				if (!newOrder.has(id)) {
+					data.push(id);
+				}
+			}
+
+			store.roots.value = data;
+			break;
+		}
 	}
 }
diff --git a/src/adapter/renderer.ts b/src/adapter/renderer.ts
index 668f525..11f6aae 100644
--- a/src/adapter/renderer.ts
+++ b/src/adapter/renderer.ts
@@ -4,6 +4,7 @@ import { InspectData, UpdateType } from "./adapter/adapter";
 import { SharedVNode } from "./shared/bindings";
 import { VNodeTimings } from "./shared/timings";
 import { RenderReasonData } from "./shared/renderReasons";
+import { RootData } from "./shared/utils";
 
 export type ObjPath = Array<string | number>;
 
@@ -31,6 +32,9 @@ export interface Renderer<T extends SharedVNode = SharedVNode> {
 
 	// Component actions
 	suspend?(id: ID, active: boolean): void; // V4
+
+	// Get a list of root node mappings
+	getRootMappings(): RootData[];
 }
 
 export function getRendererByVNodeId(
diff --git a/src/adapter/shared/renderer.ts b/src/adapter/shared/renderer.ts
index 1abca2f..57fc728 100644
--- a/src/adapter/shared/renderer.ts
+++ b/src/adapter/shared/renderer.ts
@@ -2,7 +2,7 @@ import { BaseEvent, PortPageHook } from "../adapter/port";
 import { Commit, flush } from "../protocol/events";
 import { FunctionalComponent, ComponentConstructor, Options } from "preact";
 import { ID, DevNodeType } from "../../view/store/types";
-import { traverse } from "./utils";
+import { newRootData, traverse } from "./utils";
 import { FilterState } from "../adapter/filter";
 import { Renderer } from "../renderer";
 import { startDrawing } from "../adapter/highlightUpdates";
@@ -66,12 +66,14 @@ export function createRenderer<T extends SharedVNode>(
 	filters: FilterState,
 	ids: IdMappingState<T>,
 	bindings: PreactBindings<T>,
+	roots: Map<T, Node>,
 ): Renderer<T> {
-	const roots = new Set<T>();
-
 	let currentUnmounts: number[] = [];
 	let prevOwners = new Map<T, T>();
 
+	/** Use this to check if we added a root */
+	let rootSize = roots.size;
+
 	const domToVNode = new WeakMap<HTMLElement | Text, T>();
 
 	let unmountStats: OperationInfo = {
@@ -111,10 +113,15 @@ export function createRenderer<T extends SharedVNode>(
 
 	return {
 		clear() {
-			roots.forEach(vnode => {
+			roots.forEach((dom, vnode) => {
 				onUnmount(vnode);
 			});
 		},
+		getRootMappings() {
+			return Array.from(roots.entries()).map(entry => {
+				return newRootData(getVNodeId(ids, entry[0]), entry[1]);
+			});
+		},
 
 		getVNodeById: id => getVNodeById(ids, id),
 		getDisplayName(vnode) {
@@ -194,7 +201,7 @@ export function createRenderer<T extends SharedVNode>(
 			/** Queue events and flush in one go */
 			const queue: BaseEvent<any, any>[] = [];
 
-			roots.forEach(root => {
+			roots.forEach((dom, root) => {
 				const rootId = getVNodeId(ids, root);
 
 				unmountStats = {
@@ -227,7 +234,7 @@ export function createRenderer<T extends SharedVNode>(
 			filters.regex = nextFilters.regex;
 			filters.type = nextFilters.type;
 
-			roots.forEach(root => {
+			roots.forEach((dom, root) => {
 				const commit = createCommit(
 					ids,
 					roots,
@@ -247,6 +254,7 @@ export function createRenderer<T extends SharedVNode>(
 			});
 
 			queue.forEach(ev => port.send(ev.type, ev.data));
+			port.send("root-order-page", null);
 		},
 		onCommit(vnode, owners, timingsByVNode, renderReasonPre) {
 			const commit = createCommit(
@@ -289,6 +297,10 @@ export function createRenderer<T extends SharedVNode>(
 			}
 
 			port.send(ev.type as any, ev.data);
+			if (rootSize !== roots.size) {
+				rootSize = roots.size;
+				port.send("root-order-page", null);
+			}
 		},
 		onUnmount,
 		update(id, type, path, value) {
diff --git a/src/adapter/shared/traverse.ts b/src/adapter/shared/traverse.ts
index 31d71a9..f2dbe60 100644
--- a/src/adapter/shared/traverse.ts
+++ b/src/adapter/shared/traverse.ts
@@ -573,7 +573,7 @@ function findClosestNonFilteredParent<T extends SharedVNode>(
 
 export function createCommit<T extends SharedVNode>(
 	ids: IdMappingState<T>,
-	roots: Set<T>,
+	roots: Map<T, Node>,
 	owners: Map<T, T>,
 	vnode: T,
 	filters: FilterState,
@@ -600,13 +600,22 @@ export function createCommit<T extends SharedVNode>(
 	const isNew = !hasVNodeId(ids, vnode);
 
 	if (helpers.isRoot(vnode, config)) {
+		const children = helpers.getActualChildren(vnode);
 		if (commit.stats !== null) {
-			const childrenLen = helpers.getActualChildren(vnode).length;
+			const childrenLen = children.length;
 			commit.stats.roots[childrenLen > 4 ? 4 : childrenLen]++;
 		}
 
 		parentId = -1;
-		roots.add(vnode);
+
+		if (children.length > 0 && helpers.isVNode(children[0])) {
+			const child = children[0];
+			if (roots.has(child)) {
+				const dom = roots.get(child)!;
+				roots.delete(child);
+				roots.set(vnode, dom);
+			}
+		}
 	} else {
 		parentId = findClosestNonFilteredParent(ids, helpers, vnode);
 		if (!isNew) {
diff --git a/src/adapter/shared/utils.ts b/src/adapter/shared/utils.ts
index 9a2e756..480cec9 100644
--- a/src/adapter/shared/utils.ts
+++ b/src/adapter/shared/utils.ts
@@ -20,3 +20,58 @@ export function traverse<T extends SharedVNode>(
 export function deepClone<T extends object>(obj: T): T {
 	return JSON.parse(JSON.stringify(obj));
 }
+
+export interface RootData {
+	id: number;
+	node: Node;
+}
+
+export function newRootData(id: number, node: Node) {
+	return { id, node };
+}
+
+export function sortRoots(searchRoot: Node, roots: RootData[]): number[] {
+	const map = new Map<Node, number[]>();
+
+	const walking = new Set<Node>();
+	walking.add(searchRoot);
+
+	for (let i = 0; i < roots.length; i++) {
+		let node: Node | null = roots[i].node;
+
+		// Update root map. Note that multiple roots can point
+		// to the same container node
+		if (!map.has(node)) {
+			map.set(node, []);
+		}
+		map.get(node)!.push(roots[i].id);
+
+		while (node !== null && node !== searchRoot) {
+			if (walking.has(node)) break;
+			walking.add(node);
+			node = node.parentNode;
+		}
+	}
+
+	const stack = [searchRoot] as Node[];
+	let out: number[] = [];
+
+	let item: Node | undefined;
+	while ((item = stack.pop()) !== undefined) {
+		if (!walking.has(item)) continue;
+
+		if (item.nodeName !== "IFRAME") {
+			const len = item.childNodes.length;
+			for (let i = 0; i < len; i++) {
+				stack.push(item.childNodes[len - i - 1]);
+			}
+		}
+
+		const found = map.get(item);
+		if (found !== undefined) {
+			out = out.concat(found);
+		}
+	}
+
+	return out;
+}


Test patch:
diff --git a/src/adapter/shared/renderer.test.tsx b/src/adapter/shared/renderer.test.tsx
index 9926f57..832a7cd 100644
--- a/src/adapter/shared/renderer.test.tsx
+++ b/src/adapter/shared/renderer.test.tsx
@@ -22,8 +22,9 @@ export function setupScratch() {
 
 export function setupMockHook(options: Options) {
 	const spy = sinon.spy();
+	const roots = new Map();
 	const renderer = createRenderer(
-		{ send: spy, listen: () => null },
+		{ send: spy, listen: () => null, listenToPage: () => null },
 		{ Fragment: Fragment as any },
 		{},
 		{ hooks: false, renderReasons: false },
@@ -31,8 +32,9 @@ export function setupMockHook(options: Options) {
 		{ type: new Set(), regex: [] },
 		createIdMappingState(1, bindingsV10.getInstance),
 		bindingsV10,
+		roots,
 	);
-	const destroy = setupOptionsV10(options, renderer, {
+	const destroy = setupOptionsV10(options, renderer, roots, {
 		Fragment: Fragment as any,
 	});
 	return {
@@ -42,6 +44,10 @@ export function setupMockHook(options: Options) {
 	};
 }
 
+function getOps(spy: sinon.SinonSpy) {
+	return spy.args.filter(arg => arg[0] === "operation_v2");
+}
+
 describe("Renderer 10", () => {
 	let scratch: HTMLDivElement;
 	let destroy: () => void;
@@ -63,14 +69,14 @@ describe("Renderer 10", () => {
 
 	it("should detect root nodes", () => {
 		render(<div />, scratch);
-		expect(toSnapshot(spy.args[0][1])).to.deep.equal([
+		expect(toSnapshot(getOps(spy)[0][1])).to.deep.equal([
 			"rootId: 1",
 			"Add 1 <Fragment> to parent -1",
 			"Add 2 <div> to parent 1",
 		]);
 
 		render(<div />, scratch);
-		expect(toSnapshot(spy.args[1][1])).to.deep.equal([
+		expect(toSnapshot(getOps(spy)[1][1])).to.deep.equal([
 			"rootId: 1",
 			"Update timings 1",
 			"Update timings 2",
@@ -85,7 +91,7 @@ describe("Renderer 10", () => {
 			</div>,
 			scratch,
 		);
-		expect(toSnapshot(spy.args[0][1])).to.deep.equal([
+		expect(toSnapshot(getOps(spy)[0][1])).to.deep.equal([
 			"rootId: 1",
 			"Add 1 <Fragment> to parent -1",
 			"Add 2 <div> to parent 1",
@@ -103,7 +109,7 @@ describe("Renderer 10", () => {
 			scratch,
 		);
 
-		expect(toSnapshot(spy.args[1][1])).to.deep.equal([
+		expect(toSnapshot(getOps(spy)[1][1])).to.deep.equal([
 			"rootId: 1",
 			"Add 3 <span> to parent 2",
 			"Update timings 1",
@@ -135,7 +141,7 @@ describe("Renderer 10", () => {
 			scratch,
 		);
 
-		expect(toSnapshot(spy.args[1][1])).to.deep.equal([
+		expect(toSnapshot(getOps(spy)[1][1])).to.deep.equal([
 			"rootId: 1",
 			"Add 3 <Bar> to parent 2",
 			"Update timings 1",
@@ -147,7 +153,7 @@ describe("Renderer 10", () => {
 		render(<div>foo</div>, scratch);
 		render(<div>bar</div>, scratch);
 
-		expect(toSnapshot(spy.args[1][1])).to.deep.equal([
+		expect(toSnapshot(getOps(spy)[1][1])).to.deep.equal([
 			"rootId: 1",
 			"Update timings 1",
 			"Update timings 2",
@@ -170,7 +176,7 @@ describe("Renderer 10", () => {
 			scratch,
 		);
 
-		expect(toSnapshot(spy.args[1][1])).to.deep.equal([
+		expect(toSnapshot(getOps(spy)[1][1])).to.deep.equal([
 			"rootId: 1",
 			"Update timings 1",
 			"Update timings 2",
@@ -188,7 +194,7 @@ describe("Renderer 10", () => {
 
 		render(<Foo />, scratch);
 
-		expect(toSnapshot(spy.args[0][1])).to.deep.equal([
+		expect(toSnapshot(getOps(spy)[0][1])).to.deep.equal([
 			"rootId: 1",
 			"Add 1 <Fragment> to parent -1",
 			"Add 2 <Anonymous> to parent 1",
@@ -209,7 +215,7 @@ describe("Renderer 10", () => {
 				</div>,
 				scratch,
 			);
-			expect(toSnapshot(spy.args[0][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[0][1])).to.deep.equal([
 				"rootId: 1",
 				"Add 1 <Fragment> to parent -1",
 				"Add 2 <div> to parent 1",
@@ -228,7 +234,7 @@ describe("Renderer 10", () => {
 				</div>,
 				scratch,
 			);
-			expect(toSnapshot(spy.args[0][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[0][1])).to.deep.equal([
 				"rootId: 1",
 				"Add 1 <Fragment> to parent -1",
 				"Add 2 <div> to parent 1",
@@ -247,7 +253,7 @@ describe("Renderer 10", () => {
 				</div>,
 				scratch,
 			);
-			expect(toSnapshot(spy.args[0][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[0][1])).to.deep.equal([
 				"rootId: 1",
 				"Add 1 <Fragment> to parent -1",
 			]);
@@ -270,7 +276,7 @@ describe("Renderer 10", () => {
 				</div>,
 				scratch,
 			);
-			expect(toSnapshot(spy.args[0][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[0][1])).to.deep.equal([
 				"rootId: 1",
 				"Add 1 <Fragment> to parent -1",
 				"Add 2 <Foo> to parent 1",
@@ -293,7 +299,7 @@ describe("Renderer 10", () => {
 				</div>,
 				scratch,
 			);
-			expect(toSnapshot(spy.args[0][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[0][1])).to.deep.equal([
 				"rootId: 1",
 				"Add 1 <Fragment> to parent -1",
 				"Add 2 <div> to parent 1",
@@ -325,7 +331,7 @@ describe("Renderer 10", () => {
 				scratch,
 			);
 
-			expect(toSnapshot(spy.args[0][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[0][1])).to.deep.equal([
 				"rootId: 1",
 				"Add 1 <Fragment> to parent -1",
 				"Add 2 <Parent> to parent 1",
@@ -336,7 +342,7 @@ describe("Renderer 10", () => {
 				update();
 			});
 
-			expect(toSnapshot(spy.args[1][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[1][1])).to.deep.equal([
 				"rootId: 2",
 				"Update timings 2",
 			]);
@@ -359,7 +365,7 @@ describe("Renderer 10", () => {
 				</div>,
 				scratch,
 			);
-			expect(toSnapshot(spy.args[0][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[0][1])).to.deep.equal([
 				"rootId: 1",
 				"Add 1 <Fragment> to parent -1",
 				"Add 2 <Foo> to parent 1",
@@ -370,13 +376,13 @@ describe("Renderer 10", () => {
 				type: new Set(),
 			});
 
-			expect(toSnapshot(spy.args[1][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[1][1])).to.deep.equal([
 				"rootId: 1",
 				"Remove 1",
 				"Remove 2",
 			]);
 
-			expect(toSnapshot(spy.args[2][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[2][1])).to.deep.equal([
 				"rootId: 3",
 				"Add 4 <div> to parent 3",
 				"Add 5 <Foo> to parent 4",
@@ -409,7 +415,7 @@ describe("Renderer 10", () => {
 				</div>,
 				scratch,
 			);
-			expect(toSnapshot(spy.args[0][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[0][1])).to.deep.equal([
 				"rootId: 1",
 				"Add 1 <Fragment> to parent -1",
 				"Add 2 <Foo> to parent 1",
@@ -421,13 +427,13 @@ describe("Renderer 10", () => {
 				type: new Set(),
 			});
 
-			expect(toSnapshot(spy.args[1][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[1][1])).to.deep.equal([
 				"rootId: 1",
 				"Remove 1",
 				"Remove 2",
 				"Remove 3",
 			]);
-			expect(toSnapshot(spy.args[2][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[2][1])).to.deep.equal([
 				"rootId: 4",
 				"Add 5 <div> to parent 4",
 				"Add 6 <Foo> to parent 5",
@@ -445,7 +451,7 @@ describe("Renderer 10", () => {
 				type: new Set(["dom"]),
 			});
 
-			expect(toSnapshot(spy.args[3][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[3][1])).to.deep.equal([
 				"rootId: 4",
 				"Remove 4",
 				"Remove 5",
@@ -458,7 +464,7 @@ describe("Renderer 10", () => {
 				"Remove 12",
 			]);
 
-			expect(toSnapshot(spy.args[4][1])).to.deep.equal([
+			expect(toSnapshot(getOps(spy)[4][1])).to.deep.equal([
 				"rootId: 13",
 				"Add 13 <Fragment> to parent -1",
 				"Add 14 <Foo> to parent 13",
diff --git a/src/adapter/shared/utils.test.ts b/src/adapter/shared/utils.test.ts
new file mode 100644
index 0000000..1e76d26
--- /dev/null
+++ b/src/adapter/shared/utils.test.ts
@@ -0,0 +1,102 @@
+import { expect } from "chai";
+import { sortRoots } from "./utils";
+
+const div = (id: string) => {
+	const el = document.createElement("div");
+	el.id = id;
+	return el;
+};
+
+describe("sortRoots", () => {
+	let scratch: HTMLElement;
+
+	beforeEach(() => {
+		scratch = document.createElement("div");
+		scratch.id = "scratch";
+	});
+
+	afterEach(() => {
+		scratch.remove();
+	});
+
+	it("should do nothing if no other root is known", () => {
+		const app1 = div("app-1");
+		scratch.append(app1);
+
+		const res = sortRoots(scratch, [{ id: 1, node: app1 }]);
+		expect(res).to.deep.equal([1]);
+	});
+
+	it("should sort if another root is present", () => {
+		const app1 = div("app-1");
+		const app2 = div("app-2");
+
+		scratch.append(app1);
+		scratch.append(app2);
+
+		const res = sortRoots(scratch, [
+			{ id: 2, node: app2 },
+			{ id: 1, node: app1 },
+		]);
+		expect(res).to.deep.equal([1, 2]);
+	});
+
+	it("should sort if multiple other roots are present", () => {
+		const app1 = div("app-1");
+		const app2 = div("app-2");
+		const app3 = div("app-3");
+
+		scratch.append(app1);
+		scratch.append(app2);
+		scratch.append(app3);
+
+		const res = sortRoots(scratch, [
+			{ id: 2, node: app2 },
+			{ id: 3, node: app3 },
+			{ id: 1, node: app1 },
+		]);
+		expect(res).to.deep.equal([1, 2, 3]);
+	});
+
+	it("should sort if root includes another root", () => {
+		const app1 = div("app-1");
+		const app2 = div("app-2");
+
+		scratch.append(app1);
+		app1.append(app2);
+
+		const res = sortRoots(scratch, [
+			{ id: 2, node: app2 },
+			{ id: 1, node: app1 },
+		]);
+		expect(res).to.deep.equal([1, 2]);
+	});
+
+	it("should sort in variable depth", () => {
+		const app1 = div("app-1");
+		const child1 = div("child1");
+		const app2 = div("app-2");
+
+		scratch.append(app1);
+		scratch.append(child1);
+		child1.append(app2);
+
+		const res = sortRoots(scratch, [
+			{ id: 2, node: app2 },
+			{ id: 1, node: app1 },
+		]);
+		expect(res).to.deep.equal([1, 2]);
+	});
+
+	it.skip("should sort with same container", () => {
+		const app1 = div("app-1");
+
+		scratch.append(app1);
+
+		const res = sortRoots(scratch, [
+			{ id: 2, node: app1 },
+			{ id: 1, node: app1 },
+		]);
+		expect(res).to.deep.equal([1, 2]);
+	});
+});
diff --git a/test-e2e/fixtures/apps/islands-order-virtual.jsx b/test-e2e/fixtures/apps/islands-order-virtual.jsx
new file mode 100644
index 0000000..df17f7f
--- /dev/null
+++ b/test-e2e/fixtures/apps/islands-order-virtual.jsx
@@ -0,0 +1,44 @@
+import { h, render } from "preact";
+
+function createRootFragment(parent, replaceNode) {
+	replaceNode = [].concat(replaceNode);
+	const s = replaceNode[replaceNode.length - 1].nextSibling;
+	function insert(c, r) {
+		parent.insertBefore(c, r || s);
+	}
+	// @ts-ignore this is fine
+	return (parent.__k = {
+		nodeType: 1,
+		parentNode: parent,
+		firstChild: replaceNode[0],
+		childNodes: replaceNode,
+		insertBefore: insert,
+		appendChild: insert,
+		removeChild: function (c) {
+			parent.removeChild(c);
+		},
+	});
+}
+
+const app = document.getElementById("app");
+const app2 = document.createElement("div");
+app2.id = "app-2";
+app.parentNode.append(app2);
+
+const app3 = document.createElement("div");
+app3.id = "app-3";
+app.parentNode.append(app3);
+app3.id = "virtual-1";
+app.parentNode.append(app3);
+
+const App1 = () => <h1>App1</h1>;
+const App2 = () => <h1>App2</h1>;
+const App3 = () => <h1>App3</h1>;
+const Virtual1 = () => <h1>Virtual1</h1>;
+const Virtual2 = () => <h1>Virtual2</h1>;
+
+render(<App2 />, app2);
+render(<Virtual1 />, createRootFragment(app2, app2.children));
+render(<Virtual2 />, createRootFragment(app2, app2.children));
+render(<App3 />, app3);
+render(<App1 />, app);
diff --git a/test-e2e/fixtures/apps/islands-order.jsx b/test-e2e/fixtures/apps/islands-order.jsx
new file mode 100644
index 0000000..ff4d61d
--- /dev/null
+++ b/test-e2e/fixtures/apps/islands-order.jsx
@@ -0,0 +1,18 @@
+import { h, render } from "preact";
+
+const app = document.getElementById("app");
+const app2 = document.createElement("div");
+app2.id = "app-2";
+app.parentNode.append(app2);
+
+const app3 = document.createElement("div");
+app3.id = "app-3";
+app.parentNode.append(app3);
+
+const App1 = () => <h1>App1</h1>;
+const App2 = () => <h1>App2</h1>;
+const App3 = () => <h1>App3</h1>;
+
+render(<App2 />, app2);
+render(<App3 />, app3);
+render(<App1 />, app);
diff --git a/test-e2e/fixtures/iframe1.tsx b/test-e2e/fixtures/iframe1.tsx
index 815402c..8c8be14 100644
--- a/test-e2e/fixtures/iframe1.tsx
+++ b/test-e2e/fixtures/iframe1.tsx
@@ -21,6 +21,14 @@ import { DevtoolsToClient, PageHookName } from "../../src/constants";
 			"*",
 		);
 	},
+	listenToPage: (type, callback) => {
+		window.addEventListener("message", e => {
+			if (e.source === window && e.data.source === PageHookName) {
+				const data = e.data;
+				if (data.type === type) callback(data.data);
+			}
+		});
+	},
 });
 
 (async () => {
diff --git a/test-e2e/fixtures/iframe2.tsx b/test-e2e/fixtures/iframe2.tsx
index 77d506b..20be673 100644
--- a/test-e2e/fixtures/iframe2.tsx
+++ b/test-e2e/fixtures/iframe2.tsx
@@ -21,6 +21,14 @@ import { DevtoolsToClient, PageHookName } from "../../src/constants";
 			"*",
 		);
 	},
+	listenToPage: (type, callback) => {
+		window.addEventListener("message", e => {
+			if (e.source === window && e.data.source === PageHookName) {
+				const data = e.data;
+				if (data.type === type) callback(data.data);
+			}
+		});
+	},
 });
 
 (async () => {
diff --git a/test-e2e/tests/highlight-iframe.test.ts b/test-e2e/tests/highlight-iframe.test.ts
index dff10f3..0f4f685 100644
--- a/test-e2e/tests/highlight-iframe.test.ts
+++ b/test-e2e/tests/highlight-iframe.test.ts
@@ -1,7 +1,7 @@
 import { expect, test } from "@playwright/test";
 import { gotoTest, wait } from "../pw-utils";
 
-test("Highlight iframe nodes", async ({ page }) => {
+test.only("Highlight iframe nodes", async ({ page }) => {
 	const { devtools } = await gotoTest(page, "iframe");
 
 	await devtools
diff --git a/test-e2e/tests/root-islands.test.ts b/test-e2e/tests/root-islands.test.ts
new file mode 100644
index 0000000..a43b8fa
--- /dev/null
+++ b/test-e2e/tests/root-islands.test.ts
@@ -0,0 +1,22 @@
+import { test, expect } from "@playwright/test";
+import { getTreeItems, gotoTest } from "../pw-utils";
+
+test("Islands roots should be sorted by DOM order", async ({ page }) => {
+	const { devtools } = await gotoTest(page, "islands-order");
+
+	const items = await getTreeItems(devtools);
+	expect(items.map(x => x.name)).toEqual(["App1", "App2", "App3"]);
+});
+
+test("Virtual island roots should be sorted by DOM order", async ({ page }) => {
+	const { devtools } = await gotoTest(page, "islands-order-virtual");
+
+	const items = await getTreeItems(devtools);
+	expect(items.map(x => x.name)).toEqual([
+		"App1",
+		"App2",
+		"Virtual1",
+		"Virtual2",
+		"App3",
+	]);
+});