diff --git a/docs/guide/layout.md b/docs/guide/layout.md index f1a483ec..29c24b8a 100644 --- a/docs/guide/layout.md +++ b/docs/guide/layout.md @@ -214,6 +214,51 @@ These behaviors are guaranteed by the current layout engine and validation pipel - If both `width` and `height` are already resolved, `aspectRatio` does not override them. - After derivation, min/max constraints clamp the chosen size, then final size is capped by available bounds and non-negative clamping. +## Layout Stability & Caching + +### Stability signatures (commit-time relayout checks) + +- On commit turns with `checkLayoutStability: true`, the renderer compares per-instance layout signatures against the previous committed tree. +- Signature coverage is currently limited to: `text`, `button`, `input`, `spacer`, `divider`, `row`, `column`, and `box`. +- `row`/`column` signatures include layout constraints, spacing, `pad`, `gap`, `align`, `justify`, `items`, and child order (child instance ID sequence). +- `box` signatures include layout constraints, spacing, border/title props, and child order. +- `text`/`button`/`input` signatures track intrinsic width inputs (`text` + `maxWidth`, button `label` + `px`, input `value`). + +Intentionally excluded: + +- Render-only/style props are excluded from signature checks. +- Routing/identity-only props that do not affect geometry (for example `button` `id`) are excluded. +- Text is tracked by width impact, not full content identity; equal-width edits do not force relayout. + +Conservative fallback: + +- Unsupported widget kinds or unhashable tracked prop values force relayout for that turn. +- On fallback, cached signature maps are cleared before continuing. + +### Measure cache design + +- `layout(...)` accepts an optional shared measure cache: `WeakMap`. +- Cache identity key is the VNode object; lookups are then bucketed by `(axis, maxW, maxH)`. +- Internally this is: `VNode -> (row|column) -> maxW -> maxH -> LayoutResult`. +- Same VNode identity + same axis + same constraints hits; changing any of those misses. +- Structurally equal but distinct VNode objects do not share entries. +- On commit+layout turns, the renderer resets its shared WeakMap to avoid stale cross-identity reuse. + +### Performance characteristics + +- Full layout computation is O(N) in visited nodes. +- Signature comparison is O(N) when enabled. +- When signatures are stable, the expensive `layout(...)` pass is skipped; the skip decision and reuse of the prior layout tree are O(1). +- After a commit with skipped layout, damage-rect indexes are refreshed by walking runtime nodes. + +### Clarified invariants and edge cases + +- The first signature pass against an empty previous map reports changed (bootstrap relayout). +- Child add/remove/reorder in `row`/`column`/`box` is always detected as layout-relevant. +- Style-only changes on `text`/`row`/`box` do not trigger relayout. +- `button` `id`-only changes do not trigger relayout; label/padding changes do. +- If any committed node is outside signature coverage, relayout is forced conservatively. + ## Borders `ui.box` can draw a border around its content: diff --git a/packages/core/src/app/widgetRenderer/submitFramePipeline.ts b/packages/core/src/app/widgetRenderer/submitFramePipeline.ts index 5aa67ce7..5ef35818 100644 --- a/packages/core/src/app/widgetRenderer/submitFramePipeline.ts +++ b/packages/core/src/app/widgetRenderer/submitFramePipeline.ts @@ -28,6 +28,10 @@ const STACK_LAYOUT_KEYS: readonly string[] = Object.freeze([ "m", "mx", "my", + "mt", + "mr", + "mb", + "ml", "pad", "gap", "align", @@ -54,8 +58,16 @@ const BOX_LAYOUT_KEYS: readonly string[] = Object.freeze([ "m", "mx", "my", + "mt", + "mr", + "mb", + "ml", "pad", "border", + "borderTop", + "borderRight", + "borderBottom", + "borderLeft", "title", "titleAlign", ]); diff --git a/packages/core/src/layout/__tests__/layout.measure-cache.test.ts b/packages/core/src/layout/__tests__/layout.measure-cache.test.ts index dc537bd0..d1d7553a 100644 --- a/packages/core/src/layout/__tests__/layout.measure-cache.test.ts +++ b/packages/core/src/layout/__tests__/layout.measure-cache.test.ts @@ -2,6 +2,16 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import type { VNode } from "../../index.js"; import { layout } from "../layout.js"; +function layoutResult( + node: VNode, + maxW: number, + maxH: number, + axis: "row" | "column" = "column", + measureCache?: WeakMap, +): ReturnType { + return layout(node, 0, 0, maxW, maxH, axis, measureCache); +} + function mustLayout( node: VNode, maxW: number, @@ -9,12 +19,21 @@ function mustLayout( axis: "row" | "column" = "column", measureCache?: WeakMap, ): void { - const result = layout(node, 0, 0, maxW, maxH, axis, measureCache); + const result = layoutResult(node, maxW, maxH, axis, measureCache); if (!result.ok) { assert.fail(`layout failed: ${result.fatal.code}: ${result.fatal.detail}`); } } +function expectLayoutFatal(result: ReturnType, detail: string): void { + assert.equal(result.ok, false); + if (result.ok) { + assert.fail("expected layout failure"); + } + assert.equal(result.fatal.code, "ZRUI_INVALID_PROPS"); + assert.equal(result.fatal.detail, detail); +} + function createCountingTextVNode( value: string, props: Record = {}, @@ -50,50 +69,123 @@ function rowWithChildren(children: readonly VNode[]): VNode { } as unknown as VNode; } +function columnWithChildren(children: readonly VNode[]): VNode { + return { + kind: "column", + props: {}, + children: Object.freeze([...children]), + } as unknown as VNode; +} + +function spacer(props: Record = {}): VNode { + return { + kind: "spacer", + props, + } as unknown as VNode; +} + describe("layout measure cache", () => { - test("shared cache hits for same vnode and constraints", () => { + test("same vnode + same constraints hits (column axis)", () => { const cache = new WeakMap(); const tracked = createCountingTextVNode("cache-hit"); mustLayout(tracked.vnode, 40, 5, "column", cache); - const firstReads = tracked.reads(); - assert.ok(firstReads > 0); + assert.equal(tracked.reads(), 1); mustLayout(tracked.vnode, 40, 5, "column", cache); - assert.equal(tracked.reads(), firstReads); + assert.equal(tracked.reads(), 1); }); - test("different maxW causes cache miss", () => { + test("same vnode + same constraints hits (row axis)", () => { const cache = new WeakMap(); - const tracked = createCountingTextVNode("width-miss"); + const tracked = createCountingTextVNode("cache-hit-row"); + + mustLayout(tracked.vnode, 40, 5, "row", cache); + assert.equal(tracked.reads(), 1); + + mustLayout(tracked.vnode, 40, 5, "row", cache); + assert.equal(tracked.reads(), 1); + }); + + test("maxW is independent in cache key", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("maxw-independence"); mustLayout(tracked.vnode, 40, 5, "column", cache); - const readsAfterFirst = tracked.reads(); + assert.equal(tracked.reads(), 1); + + mustLayout(tracked.vnode, 39, 5, "column", cache); + assert.equal(tracked.reads(), 2); + + mustLayout(tracked.vnode, 40, 5, "column", cache); + assert.equal(tracked.reads(), 2); mustLayout(tracked.vnode, 39, 5, "column", cache); - assert.ok(tracked.reads() > readsAfterFirst); + assert.equal(tracked.reads(), 2); }); - test("different maxH causes cache miss", () => { + test("maxH is independent in cache key", () => { const cache = new WeakMap(); - const tracked = createCountingTextVNode("height-miss"); + const tracked = createCountingTextVNode("maxh-independence"); mustLayout(tracked.vnode, 40, 5, "column", cache); - const readsAfterFirst = tracked.reads(); + assert.equal(tracked.reads(), 1); mustLayout(tracked.vnode, 40, 4, "column", cache); - assert.ok(tracked.reads() > readsAfterFirst); + assert.equal(tracked.reads(), 2); + + mustLayout(tracked.vnode, 40, 5, "column", cache); + assert.equal(tracked.reads(), 2); + + mustLayout(tracked.vnode, 40, 4, "column", cache); + assert.equal(tracked.reads(), 2); }); - test("axis change causes cache miss (row vs column)", () => { + test("axis is independent in cache key", () => { const cache = new WeakMap(); - const tracked = createCountingTextVNode("axis-miss"); + const tracked = createCountingTextVNode("axis-independence"); mustLayout(tracked.vnode, 40, 5, "column", cache); - const readsAfterFirst = tracked.reads(); + assert.equal(tracked.reads(), 1); mustLayout(tracked.vnode, 40, 5, "row", cache); - assert.ok(tracked.reads() > readsAfterFirst); + assert.equal(tracked.reads(), 2); + + mustLayout(tracked.vnode, 40, 5, "column", cache); + assert.equal(tracked.reads(), 2); + + mustLayout(tracked.vnode, 40, 5, "row", cache); + assert.equal(tracked.reads(), 2); + }); + + test("multi-constraint usage in same pass stores all encountered keys", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("same-pass-multi-constraints"); + const root = columnWithChildren([tracked.vnode, spacer({ flex: 1 })]); + + mustLayout(root, 30, 6, "column", cache); + const readsAfterFirst = tracked.reads(); + assert.ok( + readsAfterFirst >= 2, + "constraint-pass tree should measure the same leaf under multiple constraints in one pass", + ); + + mustLayout(root, 30, 6, "column", cache); + assert.equal(tracked.reads(), readsAfterFirst); + }); + + test("cross-pass reuse with shared WeakMap survives rebuilt parent identity", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("shared-cross-pass"); + const rootA = rowWithChildren([tracked.vnode]); + const rootB = rowWithChildren([tracked.vnode]); + + mustLayout(rootA, 40, 5, "column", cache); + const readsAfterA = tracked.reads(); + assert.ok(readsAfterA > 0); + + mustLayout(rootB, 40, 5, "column", cache); + assert.equal(tracked.reads(), readsAfterA); }); test("separate WeakMap instances do not share hits", () => { @@ -124,6 +216,36 @@ describe("layout measure cache", () => { assert.equal(tracked.reads(), readsAfterB); }); + test("without shared cache, repeated layout remeasures", () => { + const tracked = createCountingTextVNode("no-shared-cache"); + + mustLayout(tracked.vnode, 40, 5); + assert.equal(tracked.reads(), 1); + + mustLayout(tracked.vnode, 40, 5); + assert.equal(tracked.reads(), 2); + + mustLayout(tracked.vnode, 40, 5); + assert.equal(tracked.reads(), 3); + }); + + test("deeply cloned vnode tree misses despite identical structure", () => { + const cache = new WeakMap(); + const trackedA = createCountingTextVNode("deep-clone"); + const treeA = columnWithChildren([rowWithChildren([columnWithChildren([trackedA.vnode])])]); + + mustLayout(treeA, 50, 8, "column", cache); + const readsAfterTreeA = trackedA.reads(); + assert.ok(readsAfterTreeA > 0); + + const trackedB = createCountingTextVNode("deep-clone"); + const treeB = columnWithChildren([rowWithChildren([columnWithChildren([trackedB.vnode])])]); + + mustLayout(treeB, 50, 8, "column", cache); + assert.equal(trackedA.reads(), readsAfterTreeA); + assert.ok(trackedB.reads() > 0); + }); + test("structurally equal but distinct vnode identity misses", () => { const cache = new WeakMap(); const a = createCountingTextVNode("same-shape"); @@ -137,6 +259,23 @@ describe("layout measure cache", () => { assert.ok(b.reads() > 0); }); + test("different text content is not falsely shared", () => { + const cache = new WeakMap(); + const a = createCountingTextVNode("short"); + const b = createCountingTextVNode("a very different width"); + + mustLayout(a.vnode, 40, 5, "column", cache); + assert.equal(a.reads(), 1); + assert.equal(b.reads(), 0); + + mustLayout(b.vnode, 40, 5, "column", cache); + assert.equal(a.reads(), 1); + assert.equal(b.reads(), 1); + + mustLayout(b.vnode, 40, 5, "column", cache); + assert.equal(b.reads(), 1); + }); + test("shared cache keeps independent entries per vnode identity", () => { const cache = new WeakMap(); const a = createCountingTextVNode("A"); @@ -149,14 +288,93 @@ describe("layout measure cache", () => { assert.ok(cache.get(b.vnode) !== undefined); }); - test("without shared cache, repeated layout remeasures", () => { - const tracked = createCountingTextVNode("no-shared-cache"); + test("0 constraints are handled and cached deterministically", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("zero-constraints"); - mustLayout(tracked.vnode, 40, 5); - const readsAfterFirst = tracked.reads(); + mustLayout(tracked.vnode, 0, 0, "column", cache); + assert.equal(tracked.reads(), 1); - mustLayout(tracked.vnode, 40, 5); - assert.ok(tracked.reads() > readsAfterFirst); + mustLayout(tracked.vnode, 0, 0, "column", cache); + assert.equal(tracked.reads(), 1); + }); + + test("0 constraints for nested row validate children once per vnode key", () => { + const cache = new WeakMap(); + const a = createCountingTextVNode("zero-nested-a"); + const b = createCountingTextVNode("zero-nested-b"); + const root = rowWithChildren([a.vnode, b.vnode]); + + mustLayout(root, 0, 0, "column", cache); + assert.equal(a.reads(), 1); + assert.equal(b.reads(), 1); + + mustLayout(root, 0, 0, "column", cache); + assert.equal(a.reads(), 1); + assert.equal(b.reads(), 1); + }); + + test("Infinity maxW fails deterministically before measurement", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("invalid-maxw-inf"); + + const result = layoutResult(tracked.vnode, Number.POSITIVE_INFINITY, 5, "column", cache); + expectLayoutFatal(result, "layout: maxW must be an int32 >= 0"); + assert.equal(tracked.reads(), 0); + assert.equal(cache.get(tracked.vnode), undefined); + }); + + test("Infinity maxH fails deterministically before measurement", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("invalid-maxh-inf"); + + const result = layoutResult(tracked.vnode, 40, Number.POSITIVE_INFINITY, "column", cache); + expectLayoutFatal(result, "layout: maxH must be an int32 >= 0"); + assert.equal(tracked.reads(), 0); + assert.equal(cache.get(tracked.vnode), undefined); + }); + + test("negative maxW fails deterministically before measurement", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("invalid-maxw-negative"); + + const result = layoutResult(tracked.vnode, -1, 5, "column", cache); + expectLayoutFatal(result, "layout: maxW must be an int32 >= 0"); + assert.equal(tracked.reads(), 0); + assert.equal(cache.get(tracked.vnode), undefined); + }); + + test("negative maxH fails deterministically before measurement", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("invalid-maxh-negative"); + + const result = layoutResult(tracked.vnode, 40, -1, "column", cache); + expectLayoutFatal(result, "layout: maxH must be an int32 >= 0"); + assert.equal(tracked.reads(), 0); + assert.equal(cache.get(tracked.vnode), undefined); + }); + + test("no cache corruption from mixed constraints", () => { + const cache = new WeakMap(); + const tracked = createCountingTextVNode("mixed-constraints"); + + mustLayout(tracked.vnode, 40, 5, "column", cache); + assert.equal(tracked.reads(), 1); + + mustLayout(tracked.vnode, 39, 5, "column", cache); + assert.equal(tracked.reads(), 2); + + mustLayout(tracked.vnode, 40, 4, "column", cache); + assert.equal(tracked.reads(), 3); + + mustLayout(tracked.vnode, 40, 5, "row", cache); + assert.equal(tracked.reads(), 4); + + mustLayout(tracked.vnode, 40, 5, "column", cache); + mustLayout(tracked.vnode, 39, 5, "column", cache); + mustLayout(tracked.vnode, 40, 4, "column", cache); + mustLayout(tracked.vnode, 40, 5, "row", cache); + assert.equal(tracked.reads(), 4); }); test("nested child measurements hit cache across repeated root layouts", () => { @@ -183,4 +401,48 @@ describe("layout measure cache", () => { mustLayout(root, 39, 5, "column", cache); assert.ok(child.reads() > readsAfterFirst); }); + + test("without shared cache, nested child remeasures across root layouts", () => { + const child = createCountingTextVNode("child-no-shared-cache"); + const root = rowWithChildren([child.vnode]); + + mustLayout(root, 40, 5, "column"); + const readsAfterFirst = child.reads(); + assert.ok(readsAfterFirst > 0); + + mustLayout(root, 40, 5, "column"); + assert.ok(child.reads() > readsAfterFirst); + }); + + test("cache interaction with nested trees and selective path changes", () => { + const cache = new WeakMap(); + const stableLeaf = createCountingTextVNode("stable-branch"); + const changedLeafV1 = createCountingTextVNode("changed-branch-v1"); + const rootV1 = columnWithChildren([ + rowWithChildren([stableLeaf.vnode]), + rowWithChildren([changedLeafV1.vnode]), + ]); + + mustLayout(rootV1, 50, 8, "column", cache); + const stableReadsAfterV1 = stableLeaf.reads(); + const changedV1ReadsAfterV1 = changedLeafV1.reads(); + assert.ok(stableReadsAfterV1 > 0); + assert.ok(changedV1ReadsAfterV1 > 0); + + const changedLeafV2 = createCountingTextVNode("changed-branch-v2"); + const rootV2 = columnWithChildren([ + rowWithChildren([stableLeaf.vnode]), + rowWithChildren([changedLeafV2.vnode]), + ]); + + mustLayout(rootV2, 50, 8, "column", cache); + assert.equal(stableLeaf.reads(), stableReadsAfterV1); + assert.equal(changedLeafV1.reads(), changedV1ReadsAfterV1); + assert.ok(changedLeafV2.reads() > 0); + + const changedV2ReadsAfterFirst = changedLeafV2.reads(); + mustLayout(rootV2, 50, 8, "column", cache); + assert.equal(stableLeaf.reads(), stableReadsAfterV1); + assert.equal(changedLeafV2.reads(), changedV2ReadsAfterFirst); + }); }); diff --git a/packages/core/src/layout/__tests__/layout.perf-invariants.test.ts b/packages/core/src/layout/__tests__/layout.perf-invariants.test.ts new file mode 100644 index 00000000..9ce6d5e3 --- /dev/null +++ b/packages/core/src/layout/__tests__/layout.perf-invariants.test.ts @@ -0,0 +1,467 @@ +import { assert, describe, test } from "@rezi-ui/testkit"; +import { updateLayoutStabilitySignatures } from "../../app/widgetRenderer/submitFramePipeline.js"; +import type { VNode } from "../../index.js"; +import type { RuntimeInstance } from "../../runtime/commit.js"; +import type { InstanceId } from "../../runtime/instance.js"; +import { layout, measure } from "../layout.js"; + +type TrackedText = Readonly<{ + vnode: VNode; + reads: () => number; +}>; + +type CountingRuntimeFactory = Readonly<{ + make: ( + instanceId: InstanceId, + vnode: VNode, + children?: readonly RuntimeInstance[], + ) => RuntimeInstance; + reads: () => number; + reset: () => void; +}>; + +type TrackedNumberArray = Readonly<{ + values: readonly number[]; + reads: () => number; +}>; + +function textNode(text: string, props: Record = {}): VNode { + return { kind: "text", text, props } as unknown as VNode; +} + +function rowNode(children: readonly VNode[], props: Record = {}): VNode { + return { + kind: "row", + props, + children: Object.freeze([...children]), + } as unknown as VNode; +} + +function columnNode(children: readonly VNode[], props: Record = {}): VNode { + return { + kind: "column", + props, + children: Object.freeze([...children]), + } as unknown as VNode; +} + +const NOOP_RESIZE = (): void => {}; + +function splitPaneNode( + children: readonly VNode[], + sizes: readonly number[], + props: Record = {}, +): VNode { + return { + kind: "splitPane", + props: { + id: "split-pane-perf", + direction: "horizontal", + sizes, + onResize: NOOP_RESIZE, + ...props, + }, + children: Object.freeze([...children]), + } as unknown as VNode; +} + +function runtimeNode( + instanceId: InstanceId, + vnode: VNode, + children: readonly RuntimeInstance[] = [], +): RuntimeInstance { + return { + instanceId, + vnode, + children: Object.freeze([...children]), + }; +} + +function createTrackedText(value: string): TrackedText { + let readCount = 0; + const node = { kind: "text", props: {} } as { + kind: "text"; + props: Record; + text?: string; + }; + Object.defineProperty(node, "text", { + configurable: true, + enumerable: true, + get: () => { + readCount++; + return value; + }, + }); + return Object.freeze({ + vnode: node as unknown as VNode, + reads: () => readCount, + }); +} + +function totalTrackedReads(tracked: readonly TrackedText[]): number { + let total = 0; + for (const item of tracked) { + total += item.reads(); + } + return total; +} + +function buildWideTrackedColumn( + count: number, +): Readonly<{ root: VNode; leaves: readonly TrackedText[] }> { + const leaves: TrackedText[] = []; + for (let i = 0; i < count; i++) { + leaves.push(createTrackedText(`leaf-${String(i)}`)); + } + return Object.freeze({ + root: columnNode(leaves.map((leaf) => leaf.vnode)), + leaves: Object.freeze(leaves), + }); +} + +function createCountingRuntimeFactory(): CountingRuntimeFactory { + let childPropertyReads = 0; + return Object.freeze({ + make: ( + instanceId: InstanceId, + vnode: VNode, + children: readonly RuntimeInstance[] = [], + ): RuntimeInstance => { + const frozenChildren = Object.freeze([...children]); + const node = { instanceId, vnode } as { + instanceId: InstanceId; + vnode: VNode; + children?: readonly RuntimeInstance[]; + }; + Object.defineProperty(node, "children", { + configurable: true, + enumerable: true, + get: () => { + childPropertyReads++; + return frozenChildren; + }, + }); + return node as RuntimeInstance; + }, + reads: () => childPropertyReads, + reset: () => { + childPropertyReads = 0; + }, + }); +} + +function buildWideRuntimeRow(leafCount: number, factory: CountingRuntimeFactory): RuntimeInstance { + const leaves: RuntimeInstance[] = []; + for (let i = 0; i < leafCount; i++) { + leaves.push(factory.make(i + 2, textNode(`leaf-${String(i)}`))); + } + return factory.make(1, rowNode(leaves.map((leaf) => leaf.vnode)), leaves); +} + +function updateSignatures(root: RuntimeInstance, prevById: Map): boolean { + const nextById = new Map(); + const stack: RuntimeInstance[] = []; + return updateLayoutStabilitySignatures(root, prevById, nextById, stack); +} + +function signatureGateLayout( + root: RuntimeInstance, + prevById: Map, + onLayout: () => void, +): void { + if (updateSignatures(root, prevById)) { + onLayout(); + } +} + +function createTrackedNumberArray(length: number, value: number): TrackedNumberArray { + let reads = 0; + const values = new Array(length); + for (let i = 0; i < length; i++) { + Object.defineProperty(values, i, { + configurable: true, + enumerable: true, + get: () => { + reads++; + return value; + }, + }); + } + return Object.freeze({ + values: Object.freeze(values) as readonly number[], + reads: () => reads, + }); +} + +function buildDeepColumn(depth: number): VNode { + let node: VNode = textNode("leaf"); + for (let i = 0; i < depth; i++) { + node = columnNode([node]); + } + return node; +} + +function buildTextChildren(count: number): readonly VNode[] { + const out: VNode[] = []; + for (let i = 0; i < count; i++) { + out.push(textNode(`panel-${String(i)}`)); + } + return Object.freeze(out); +} + +describe("layout perf invariants (deterministic counters)", () => { + test("layout text traversal for wide columns is exactly 2N reads", () => { + const leafCount = 12; + const tree = buildWideTrackedColumn(leafCount); + const result = layout(tree.root, 0, 0, 120, leafCount + 2, "column"); + assert.equal(result.ok, true); + assert.equal(totalTrackedReads(tree.leaves), leafCount * 2); + }); + + test("layout text traversal scales linearly with deterministic node counts", () => { + const small = buildWideTrackedColumn(12); + const medium = buildWideTrackedColumn(24); + const large = buildWideTrackedColumn(48); + + const smallRes = layout(small.root, 0, 0, 120, 64, "column"); + const mediumRes = layout(medium.root, 0, 0, 120, 64, "column"); + const largeRes = layout(large.root, 0, 0, 120, 64, "column"); + + assert.equal(smallRes.ok, true); + assert.equal(mediumRes.ok, true); + assert.equal(largeRes.ok, true); + + const readsSmall = totalTrackedReads(small.leaves); + const readsMedium = totalTrackedReads(medium.leaves); + const readsLarge = totalTrackedReads(large.leaves); + + assert.deepEqual([readsSmall, readsMedium, readsLarge], [24, 48, 96]); + assert.deepEqual([readsMedium - readsSmall, readsLarge - readsMedium], [24, 48]); + }); + + test("signature traversal for wide rows is exactly 2N+3 children reads", () => { + const leafCount = 12; + const prev = new Map(); + const factory = createCountingRuntimeFactory(); + const root = buildWideRuntimeRow(leafCount, factory); + + const changed = updateSignatures(root, prev); + assert.equal(changed, true); + assert.equal(factory.reads(), leafCount * 2 + 3); + assert.equal(prev.size, leafCount + 1); + }); + + test("signature traversal counter scales linearly with node count", () => { + const prevSmall = new Map(); + const prevLarge = new Map(); + + const factorySmall = createCountingRuntimeFactory(); + const factoryLarge = createCountingRuntimeFactory(); + + const rootSmall = buildWideRuntimeRow(10, factorySmall); + const rootLarge = buildWideRuntimeRow(30, factoryLarge); + + assert.equal(updateSignatures(rootSmall, prevSmall), true); + assert.equal(updateSignatures(rootLarge, prevLarge), true); + + const readsSmall = factorySmall.reads(); + const readsLarge = factoryLarge.reads(); + + assert.deepEqual([readsSmall, readsLarge], [23, 63]); + assert.equal(readsLarge - readsSmall, (30 - 10) * 2); + }); + + test("signature gate invokes layout first pass and skips unchanged pass", () => { + const prev = new Map(); + let layoutCalls = 0; + + const childA0 = runtimeNode(2, textNode("stable")); + const root0 = runtimeNode(1, rowNode([childA0.vnode]), [childA0]); + signatureGateLayout(root0, prev, () => { + layoutCalls++; + }); + + const childA1 = runtimeNode(2, textNode("stable")); + const root1 = runtimeNode(1, rowNode([childA1.vnode]), [childA1]); + signatureGateLayout(root1, prev, () => { + layoutCalls++; + }); + + assert.equal(layoutCalls, 1); + assert.equal(prev.size, 2); + }); + + test("signature gate invokes layout when leaf signature changes", () => { + const prev = new Map(); + let layoutCalls = 0; + + const childA0 = runtimeNode(2, textNode("stable")); + const root0 = runtimeNode(1, rowNode([childA0.vnode]), [childA0]); + signatureGateLayout(root0, prev, () => { + layoutCalls++; + }); + + const childA1 = runtimeNode(2, textNode("stable-updated")); + const root1 = runtimeNode(1, rowNode([childA1.vnode]), [childA1]); + signatureGateLayout(root1, prev, () => { + layoutCalls++; + }); + + assert.equal(layoutCalls, 2); + assert.equal(prev.size, 2); + }); + + test("unsupported signature kinds force relayout and clear signature map", () => { + const prev = new Map(); + let layoutCalls = 0; + + const supported = runtimeNode(1, textNode("ok")); + signatureGateLayout(supported, prev, () => { + layoutCalls++; + }); + assert.equal(layoutCalls, 1); + assert.equal(prev.size, 1); + + const unsupported = runtimeNode(1, { + kind: "select", + props: { id: "s", value: "", options: Object.freeze([]) }, + } as unknown as VNode); + signatureGateLayout(unsupported, prev, () => { + layoutCalls++; + }); + assert.equal(layoutCalls, 2); + assert.equal(prev.size, 0); + + signatureGateLayout(unsupported, prev, () => { + layoutCalls++; + }); + assert.equal(layoutCalls, 3); + assert.equal(prev.size, 0); + }); + + test("single-leaf replacement remeasures only changed path with shared cache", () => { + const cache = new WeakMap(); + + const leftA = createTrackedText("left-a"); + const leftB = createTrackedText("left-b"); + const rightA = createTrackedText("right-a"); + const rightB = createTrackedText("right-b"); + + const rightBranch = columnNode([rightA.vnode, rightB.vnode], { width: 20 }); + const leftBranch0 = columnNode([leftA.vnode, leftB.vnode], { width: 20 }); + const root0 = rowNode([leftBranch0, rightBranch], { width: 40, height: 5 }); + + const res0 = layout(root0, 0, 0, 40, 5, "column", cache); + assert.equal(res0.ok, true); + assert.deepEqual([leftA.reads(), leftB.reads(), rightA.reads(), rightB.reads()], [2, 2, 2, 2]); + + const leftBChanged = createTrackedText("left-b-updated"); + const leftBranch1 = columnNode([leftA.vnode, leftBChanged.vnode], { width: 20 }); + const root1 = rowNode([leftBranch1, rightBranch], { width: 40, height: 5 }); + + const res1 = layout(root1, 0, 0, 40, 5, "column", cache); + assert.equal(res1.ok, true); + assert.deepEqual( + [leftA.reads(), leftB.reads(), rightA.reads(), rightB.reads(), leftBChanged.reads()], + [2, 2, 2, 2, 2], + ); + }); + + test("without shared cache unchanged leaves remeasure after single-leaf replacement", () => { + const leftA = createTrackedText("left-a"); + const leftB = createTrackedText("left-b"); + const rightA = createTrackedText("right-a"); + const rightB = createTrackedText("right-b"); + + const rightBranch = columnNode([rightA.vnode, rightB.vnode], { width: 20 }); + const leftBranch0 = columnNode([leftA.vnode, leftB.vnode], { width: 20 }); + const root0 = rowNode([leftBranch0, rightBranch], { width: 40, height: 5 }); + + const res0 = layout(root0, 0, 0, 40, 5, "column"); + assert.equal(res0.ok, true); + assert.deepEqual([leftA.reads(), leftB.reads(), rightA.reads(), rightB.reads()], [2, 2, 2, 2]); + + const leftBChanged = createTrackedText("left-b-updated"); + const leftBranch1 = columnNode([leftA.vnode, leftBChanged.vnode], { width: 20 }); + const root1 = rowNode([leftBranch1, rightBranch], { width: 40, height: 5 }); + + const res1 = layout(root1, 0, 0, 40, 5, "column"); + assert.equal(res1.ok, true); + assert.deepEqual( + [leftA.reads(), leftB.reads(), rightA.reads(), rightB.reads(), leftBChanged.reads()], + [4, 2, 4, 4, 2], + ); + }); + + test("splitPane layout reads sizes once per panel and honors absolute widths", () => { + const panelCount = 8; + const sizes = createTrackedNumberArray(panelCount, 10); + const split = splitPaneNode(buildTextChildren(panelCount), sizes.values, { + sizeMode: "absolute", + dividerSize: 0, + }); + + const laidOut = layout(split, 0, 0, 80, 4, "column"); + assert.equal(laidOut.ok, true); + if (!laidOut.ok) return; + + assert.equal(sizes.reads(), panelCount); + assert.deepEqual( + laidOut.value.children.map((child, index) => child.rect.x - index * 10), + Object.freeze(new Array(panelCount).fill(0)), + ); + }); + + test("splitPane layout size-read count grows linearly with panel count", () => { + const smallPanelCount = 4; + const largePanelCount = 12; + const smallSizes = createTrackedNumberArray(smallPanelCount, 10); + const largeSizes = createTrackedNumberArray(largePanelCount, 10); + + const small = splitPaneNode(buildTextChildren(smallPanelCount), smallSizes.values, { + sizeMode: "absolute", + dividerSize: 0, + }); + const large = splitPaneNode(buildTextChildren(largePanelCount), largeSizes.values, { + sizeMode: "absolute", + dividerSize: 0, + }); + + assert.equal(layout(small, 0, 0, 40, 4, "column").ok, true); + assert.equal(layout(large, 0, 0, 120, 4, "column").ok, true); + assert.deepEqual([smallSizes.reads(), largeSizes.reads()], [smallPanelCount, largePanelCount]); + }); + + test("splitPane layout min/max reads scale deterministically with panel count", () => { + const panelCount = 10; + const sizes = createTrackedNumberArray(panelCount, 10); + const minSizes = createTrackedNumberArray(panelCount, 0); + const maxSizes = createTrackedNumberArray(panelCount, 20); + const split = splitPaneNode(buildTextChildren(panelCount), sizes.values, { + sizeMode: "absolute", + dividerSize: 0, + minSizes: minSizes.values, + maxSizes: maxSizes.values, + }); + + const laidOut = layout(split, 0, 0, 100, 4, "column"); + assert.equal(laidOut.ok, true); + assert.deepEqual([sizes.reads(), minSizes.reads(), maxSizes.reads()], [10, 10, 10]); + }); + + test("measure handles 128-level nesting without overflow", () => { + const deep = buildDeepColumn(128); + const measured = measure(deep, 80, 300, "column"); + assert.equal(measured.ok, true); + if (!measured.ok) return; + assert.deepEqual(measured.value, { w: 4, h: 1 }); + }); + + test("layout handles 192-level nesting without overflow", () => { + const deep = buildDeepColumn(192); + const laidOut = layout(deep, 0, 0, 80, 300, "column"); + assert.equal(laidOut.ok, true); + if (!laidOut.ok) return; + assert.deepEqual(laidOut.value.rect, { x: 0, y: 0, w: 4, h: 1 }); + }); +}); diff --git a/packages/core/src/layout/__tests__/layout.stability-signature.test.ts b/packages/core/src/layout/__tests__/layout.stability-signature.test.ts index 0639605d..17c86674 100644 --- a/packages/core/src/layout/__tests__/layout.stability-signature.test.ts +++ b/packages/core/src/layout/__tests__/layout.stability-signature.test.ts @@ -20,6 +20,14 @@ function rowNode(children: readonly VNode[], props: Record = {} } as unknown as VNode; } +function columnNode(children: readonly VNode[], props: Record = {}): VNode { + return { + kind: "column", + props, + children: Object.freeze([...children]), + } as unknown as VNode; +} + function boxNode(children: readonly VNode[], props: Record = {}): VNode { return { kind: "box", @@ -28,6 +36,33 @@ function boxNode(children: readonly VNode[], props: Record = {} } as unknown as VNode; } +function spacerNode(props: Record = {}): VNode { + return { kind: "spacer", props } as unknown as VNode; +} + +function modalNode(content: VNode, props: Record = {}): VNode { + return { + kind: "modal", + props: { id: "overlay-modal", content, ...props }, + } as unknown as VNode; +} + +const NOOP_RESIZE = (): void => {}; + +function splitPaneNode(children: readonly VNode[], props: Record = {}): VNode { + return { + kind: "splitPane", + props: { + id: "split-pane", + direction: "horizontal", + sizes: Object.freeze([50, 50]), + onResize: NOOP_RESIZE, + ...props, + }, + children: Object.freeze([...children]), + } as unknown as VNode; +} + function runtimeNode( instanceId: InstanceId, vnode: VNode, @@ -46,7 +81,95 @@ function runSignatures(root: RuntimeInstance, prev: Map): bo return updateLayoutStabilitySignatures(root, prev, next, stack); } +function expectSignatureChanged(base: RuntimeInstance, changed: RuntimeInstance): void { + const prev = new Map(); + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(changed, prev), true); +} + +function expectSignatureUnchanged(base: RuntimeInstance, changed: RuntimeInstance): void { + const prev = new Map(); + assert.equal(runSignatures(base, prev), true); + assert.equal(runSignatures(changed, prev), false); +} + +function sortedEntries( + map: ReadonlyMap, +): readonly (readonly [string, number])[] { + return Object.freeze( + [...map.entries()] + .map(([instanceId, signature]) => [String(instanceId), signature] as const) + .sort((a, b) => a[0].localeCompare(b[0])), + ); +} + describe("layout stability signatures", () => { + const STACK_LAYOUT_PROP_CASES = Object.freeze([ + { name: "width", base: { width: 10 }, changed: { width: 11 } }, + { name: "height", base: { height: 4 }, changed: { height: 5 } }, + { name: "minWidth", base: { minWidth: 5 }, changed: { minWidth: 6 } }, + { name: "maxWidth", base: { maxWidth: 20 }, changed: { maxWidth: 21 } }, + { name: "minHeight", base: { minHeight: 2 }, changed: { minHeight: 3 } }, + { name: "maxHeight", base: { maxHeight: 8 }, changed: { maxHeight: 9 } }, + { name: "flex", base: { flex: 1 }, changed: { flex: 2 } }, + { name: "aspectRatio", base: { aspectRatio: 2 }, changed: { aspectRatio: 3 } }, + { name: "p", base: { p: 1 }, changed: { p: 2 } }, + { name: "px", base: { px: 1 }, changed: { px: 2 } }, + { name: "py", base: { py: 1 }, changed: { py: 2 } }, + { name: "pt", base: { pt: 1 }, changed: { pt: 2 } }, + { name: "pr", base: { pr: 1 }, changed: { pr: 2 } }, + { name: "pb", base: { pb: 1 }, changed: { pb: 2 } }, + { name: "pl", base: { pl: 1 }, changed: { pl: 2 } }, + { name: "m", base: { m: 1 }, changed: { m: 2 } }, + { name: "mx", base: { mx: 1 }, changed: { mx: 2 } }, + { name: "my", base: { my: 1 }, changed: { my: 2 } }, + { name: "mt (regression)", base: { mt: 1 }, changed: { mt: 2 } }, + { name: "mr (regression)", base: { mr: 1 }, changed: { mr: 2 } }, + { name: "mb (regression)", base: { mb: 1 }, changed: { mb: 2 } }, + { name: "ml (regression)", base: { ml: 1 }, changed: { ml: 2 } }, + { name: "gap", base: { gap: 1 }, changed: { gap: 2 } }, + { name: "align", base: { align: "start" }, changed: { align: "center" } }, + { name: "justify", base: { justify: "start" }, changed: { justify: "end" } }, + ] as const); + + const BOX_LAYOUT_PROP_CASES = Object.freeze([ + { name: "border", base: { border: "single" }, changed: { border: "double" } }, + { + name: "borderTop (regression)", + base: { borderTop: true }, + changed: { borderTop: false }, + }, + { + name: "borderRight (regression)", + base: { borderRight: true }, + changed: { borderRight: false }, + }, + { + name: "borderBottom (regression)", + base: { borderBottom: true }, + changed: { borderBottom: false }, + }, + { + name: "borderLeft (regression)", + base: { borderLeft: true }, + changed: { borderLeft: false }, + }, + { name: "mt (regression)", base: { mt: 1 }, changed: { mt: 2 } }, + { name: "mr (regression)", base: { mr: 1 }, changed: { mr: 2 } }, + { name: "mb (regression)", base: { mb: 1 }, changed: { mb: 2 } }, + { name: "ml (regression)", base: { ml: 1 }, changed: { ml: 2 } }, + ] as const); + + const STYLE_ONLY_CASES = Object.freeze([ + { name: "fg", base: { fg: "red" }, changed: { fg: "blue" } }, + { name: "bg", base: { bg: "black" }, changed: { bg: "white" } }, + { name: "bold", base: { bold: false }, changed: { bold: true } }, + { name: "dim", base: { dim: false }, changed: { dim: true } }, + { name: "italic", base: { italic: false }, changed: { italic: true } }, + { name: "underline", base: { underline: false }, changed: { underline: true } }, + { name: "inverse", base: { inverse: false }, changed: { inverse: true } }, + ] as const); + test("second pass on unchanged tree reports no change", () => { const prev = new Map(); const root = runtimeNode(1, textNode("stable")); @@ -55,94 +178,87 @@ describe("layout stability signatures", () => { assert.equal(runSignatures(root, prev), false); }); - test("row layout-relevant prop change is included", () => { - const prev = new Map(); + test("determinism across separate computations", () => { const child = runtimeNode(2, textNode("child")); - const base = runtimeNode(1, rowNode([child.vnode], { gap: 1 }), [child]); - const changed = runtimeNode(1, rowNode([child.vnode], { gap: 2 }), [child]); - - assert.equal(runSignatures(base, prev), true); - assert.equal(runSignatures(changed, prev), true); + const root = runtimeNode(1, rowNode([child.vnode], { gap: 1, width: 12 }), [child]); + + const prevA = new Map(); + const prevB = new Map(); + assert.equal(runSignatures(root, prevA), true); + assert.equal(runSignatures(root, prevB), true); + assert.deepEqual(sortedEntries(prevA), sortedEntries(prevB)); + assert.equal(runSignatures(root, prevA), false); + assert.equal(runSignatures(root, prevB), false); }); - test("row style-only prop change is excluded", () => { + test("empty tree is deterministic", () => { const prev = new Map(); - const child = runtimeNode(2, textNode("child")); - const base = runtimeNode(1, rowNode([child.vnode], { gap: 1, style: { fg: "red" } }), [child]); - const styleOnly = runtimeNode(1, rowNode([child.vnode], { gap: 1, style: { fg: "blue" } }), [ - child, - ]); - - assert.equal(runSignatures(base, prev), true); - assert.equal(runSignatures(styleOnly, prev), false); + const root = runtimeNode(1, rowNode([], { gap: 1 })); + assert.equal(runSignatures(root, prev), true); + assert.equal(runSignatures(root, prev), false); }); - test("box layout-relevant prop change is included", () => { + test("single node tree is deterministic", () => { const prev = new Map(); - const child = runtimeNode(2, textNode("child")); - const base = runtimeNode(1, boxNode([child.vnode], { border: "single", pad: 1 }), [child]); - const changed = runtimeNode(1, boxNode([child.vnode], { border: "single", pad: 2 }), [child]); - - assert.equal(runSignatures(base, prev), true); - assert.equal(runSignatures(changed, prev), true); + const root = runtimeNode(1, buttonNode("Go", { px: 2 })); + assert.equal(runSignatures(root, prev), true); + assert.equal(runSignatures(root, prev), false); }); - test("box style-only prop change is excluded", () => { - const prev = new Map(); - const child = runtimeNode(2, textNode("child")); - const base = runtimeNode( - 1, - boxNode([child.vnode], { border: "single", pad: 1, style: { fg: "red" } }), - [child], - ); - const styleOnly = runtimeNode( - 1, - boxNode([child.vnode], { border: "single", pad: 1, style: { fg: "green" } }), - [child], - ); - - assert.equal(runSignatures(base, prev), true); - assert.equal(runSignatures(styleOnly, prev), false); - }); + for (const c of STACK_LAYOUT_PROP_CASES) { + test(`row layout-relevant ${c.name} change is included`, () => { + const child = runtimeNode(2, textNode("child")); + const base = runtimeNode(1, rowNode([child.vnode], c.base), [child]); + const changed = runtimeNode(1, rowNode([child.vnode], c.changed), [child]); + expectSignatureChanged(base, changed); + }); + } + + for (const c of BOX_LAYOUT_PROP_CASES) { + test(`box layout-relevant ${c.name} change is included`, () => { + const child = runtimeNode(2, textNode("child")); + const base = runtimeNode(1, boxNode([child.vnode], c.base), [child]); + const changed = runtimeNode(1, boxNode([child.vnode], c.changed), [child]); + expectSignatureChanged(base, changed); + }); + } + + for (const c of STYLE_ONLY_CASES) { + test(`row style-only ${c.name} change is excluded`, () => { + const child = runtimeNode(2, textNode("child")); + const base = runtimeNode(1, rowNode([child.vnode], { gap: 1, style: c.base }), [child]); + const styleOnly = runtimeNode(1, rowNode([child.vnode], { gap: 1, style: c.changed }), [ + child, + ]); + expectSignatureUnchanged(base, styleOnly); + }); + } test("text content change is included", () => { - const prev = new Map(); const base = runtimeNode(1, textNode("a")); const changed = runtimeNode(1, textNode("aaaa")); - - assert.equal(runSignatures(base, prev), true); - assert.equal(runSignatures(changed, prev), true); + expectSignatureChanged(base, changed); }); test("text style-only change is excluded", () => { - const prev = new Map(); const base = runtimeNode(1, textNode("abc", { style: { fg: "red" } })); const styleOnly = runtimeNode(1, textNode("abc", { style: { fg: "blue" } })); - - assert.equal(runSignatures(base, prev), true); - assert.equal(runSignatures(styleOnly, prev), false); + expectSignatureUnchanged(base, styleOnly); }); test("button label change is included", () => { - const prev = new Map(); const base = runtimeNode(1, buttonNode("Go", { id: "btn-a", px: 1 })); const changed = runtimeNode(1, buttonNode("Launch", { id: "btn-a", px: 1 })); - - assert.equal(runSignatures(base, prev), true); - assert.equal(runSignatures(changed, prev), true); + expectSignatureChanged(base, changed); }); test("button id-only change is excluded", () => { - const prev = new Map(); const base = runtimeNode(1, buttonNode("Go", { id: "btn-a", px: 2 })); const idOnly = runtimeNode(1, buttonNode("Go", { id: "btn-b", px: 2 })); - - assert.equal(runSignatures(base, prev), true); - assert.equal(runSignatures(idOnly, prev), false); + expectSignatureUnchanged(base, idOnly); }); test("adding a child is included", () => { - const prev = new Map(); const childA = runtimeNode(2, textNode("A")); const childB = runtimeNode(3, textNode("B")); @@ -151,13 +267,10 @@ describe("layout stability signatures", () => { childA, childB, ]); - - assert.equal(runSignatures(base, prev), true); - assert.equal(runSignatures(withAddedChild, prev), true); + expectSignatureChanged(base, withAddedChild); }); test("removing a child is included", () => { - const prev = new Map(); const childA = runtimeNode(2, textNode("A")); const childB = runtimeNode(3, textNode("B")); @@ -166,13 +279,10 @@ describe("layout stability signatures", () => { childB, ]); const withRemovedChild = runtimeNode(1, rowNode([childA.vnode], { gap: 0 }), [childA]); - - assert.equal(runSignatures(base, prev), true); - assert.equal(runSignatures(withRemovedChild, prev), true); + expectSignatureChanged(base, withRemovedChild); }); test("reordering children is included", () => { - const prev = new Map(); const childA = runtimeNode(2, textNode("A")); const childB = runtimeNode(3, textNode("B")); @@ -184,9 +294,121 @@ describe("layout stability signatures", () => { childB, childA, ]); + expectSignatureChanged(base, reordered); + }); + + test("same child count but different child identity is included", () => { + const childA = runtimeNode(2, textNode("same")); + const childB = runtimeNode(3, textNode("same")); + const base = runtimeNode(1, rowNode([childA.vnode], { gap: 0 }), [childA]); + const differentIdentity = runtimeNode(1, rowNode([childB.vnode], { gap: 0 }), [childB]); + expectSignatureChanged(base, differentIdentity); + }); + + test("changing child key (new child instance id) changes signature", () => { + const keyedChildA = runtimeNode(2, textNode("stable", { key: "a" })); + const keyedChildB = runtimeNode(3, textNode("stable", { key: "b" })); + const base = runtimeNode(1, rowNode([keyedChildA.vnode], { gap: 1 }), [keyedChildA]); + const keyChanged = runtimeNode(1, rowNode([keyedChildB.vnode], { gap: 1 }), [keyedChildB]); + expectSignatureChanged(base, keyChanged); + }); + + test("mixed supported kinds remain stable when unchanged", () => { + const button = runtimeNode(2, buttonNode("Go", { px: 2 })); + const spacer = runtimeNode(3, spacerNode({ size: 1 })); + const text = runtimeNode(5, textNode("inside")); + const box = runtimeNode(4, boxNode([text.vnode], { border: "single", pad: 1 }), [text]); + const root = runtimeNode(1, columnNode([button.vnode, spacer.vnode, box.vnode], { gap: 1 }), [ + button, + spacer, + box, + ]); + const prev = new Map(); + assert.equal(runSignatures(root, prev), true); + assert.equal(runSignatures(root, prev), false); + }); + + test("mixed supported kinds detect nested content changes", () => { + const button = runtimeNode(2, buttonNode("Go", { px: 2 })); + const textA = runtimeNode(5, textNode("inside")); + const textB = runtimeNode(5, textNode("inside-changed")); + const boxA = runtimeNode(4, boxNode([textA.vnode], { border: "single", pad: 1 }), [textA]); + const boxB = runtimeNode(4, boxNode([textB.vnode], { border: "single", pad: 1 }), [textB]); + const base = runtimeNode(1, columnNode([button.vnode, boxA.vnode], { gap: 1 }), [button, boxA]); + const changed = runtimeNode(1, columnNode([button.vnode, boxB.vnode], { gap: 1 }), [ + button, + boxB, + ]); + expectSignatureChanged(base, changed); + }); + + test("overlay kinds are excluded and conservatively force relayout", () => { + const prev = new Map(); + const supported = runtimeNode(1, textNode("ok")); + assert.equal(runSignatures(supported, prev), true); + assert.ok(prev.size > 0); + + const overlay = runtimeNode(1, modalNode(textNode("overlay"))); + assert.equal(runSignatures(overlay, prev), true); + assert.equal(prev.size, 0); + }); + + test("splitPane kind is excluded and conservatively forces relayout", () => { + const prev = new Map(); + const supported = runtimeNode(1, textNode("ok")); + assert.equal(runSignatures(supported, prev), true); + assert.ok(prev.size > 0); + + const childA = runtimeNode(2, textNode("A")); + const childB = runtimeNode(3, textNode("B")); + const split = runtimeNode( + 1, + splitPaneNode([childA.vnode, childB.vnode], { sizes: Object.freeze([50, 50]) }), + [childA, childB], + ); + assert.equal(runSignatures(split, prev), true); + assert.equal(prev.size, 0); + }); + + test("splitPane sizes array changes are covered by conservative relayout behavior", () => { + const prev = new Map(); + const childA = runtimeNode(2, textNode("A")); + const childB = runtimeNode(3, textNode("B")); + + const base = runtimeNode( + 1, + splitPaneNode([childA.vnode, childB.vnode], { sizes: Object.freeze([50, 50]) }), + [childA, childB], + ); + const changedSizes = runtimeNode( + 1, + splitPaneNode([childA.vnode, childB.vnode], { sizes: Object.freeze([60, 40]) }), + [childA, childB], + ); assert.equal(runSignatures(base, prev), true); - assert.equal(runSignatures(reordered, prev), true); + assert.equal(prev.size, 0); + assert.equal(runSignatures(changedSizes, prev), true); + assert.equal(prev.size, 0); + }); + + test("unsupported child in otherwise supported tree forces relayout and clears maps", () => { + const prev = new Map(); + const supportedRoot = runtimeNode(1, rowNode([textNode("ok")], { gap: 1 }), [ + runtimeNode(2, textNode("ok")), + ]); + assert.equal(runSignatures(supportedRoot, prev), true); + assert.ok(prev.size > 0); + + const supportedChild = runtimeNode(2, textNode("ok")); + const unsupportedChild = runtimeNode(3, modalNode(textNode("overlay-child"))); + const mixed = runtimeNode( + 1, + rowNode([supportedChild.vnode, unsupportedChild.vnode], { gap: 1 }), + [supportedChild, unsupportedChild], + ); + assert.equal(runSignatures(mixed, prev), true); + assert.equal(prev.size, 0); }); test("unsupported kinds conservatively force relayout and clear maps", () => {