Skip to content

Commit bdf2a92

Browse files
committed
Clamp layer zIndex to safe range
1 parent 1e93e80 commit bdf2a92

4 files changed

Lines changed: 110 additions & 4 deletions

File tree

docs/widgets/layer.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ ui.layer({
1818
| Prop | Type | Default | Description |
1919
|------|------|---------|-------------|
2020
| `id` | `string` | **required** | Layer identifier |
21-
| `zIndex` | `number` | insertion order | Higher values render on top |
21+
| `zIndex` | `number` | insertion order | Higher values render on top (clamped for deterministic ordering) |
2222
| `backdrop` | `"none" \| "dim" \| "opaque"` | `"none"` | Backdrop behind the layer |
2323
| `modal` | `boolean` | `false` | Block input to lower layers |
2424
| `closeOnEscape` | `boolean` | `true` | Close on Escape key |
@@ -33,6 +33,7 @@ ui.layer({
3333
## Notes
3434

3535
- Use [`Layers`](layers.md) to manage stacking order and modals.
36+
- `zIndex` is truncated to an integer and clamped to the safe range (`±9,007,199,253`) so very large values don't break ordering.
3637
- `BackdropStyle` values: `"none"`, `"dim"`, `"opaque"`.
3738
- Backdrops are rendered behind the layer. `"dim"` uses a light shade pattern; `"opaque"` clears the area behind the layer to the theme background color.
3839

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { assert, describe, test } from "@rezi-ui/testkit";
2+
import type { RuntimeBackend } from "../../backend.js";
3+
import { ui } from "../../index.js";
4+
import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js";
5+
import { defaultTheme } from "../../theme/defaultTheme.js";
6+
import { WidgetRenderer } from "../widgetRenderer.js";
7+
8+
function noRenderHooks(): { enterRender: () => void; exitRender: () => void } {
9+
return { enterRender: () => {}, exitRender: () => {} };
10+
}
11+
12+
function createNoopBackend(): RuntimeBackend {
13+
return {
14+
start: async () => {},
15+
stop: async () => {},
16+
dispose: () => {},
17+
requestFrame: async () => {},
18+
pollEvents: async () =>
19+
new Promise((_) => {
20+
// Not used by WidgetRenderer unit-style tests.
21+
}),
22+
postUserEvent: () => {},
23+
getCaps: async () => DEFAULT_TERMINAL_CAPS,
24+
};
25+
}
26+
27+
describe("layer zIndex clamping", () => {
28+
test("ui.layer zIndex is clamped to a safe integer range", () => {
29+
const backend = createNoopBackend();
30+
const renderer = new WidgetRenderer<void>({
31+
backend,
32+
requestRender: () => {},
33+
});
34+
35+
const view = () =>
36+
ui.layers([
37+
ui.text("base"),
38+
ui.layer({
39+
id: "a",
40+
zIndex: Number.MAX_SAFE_INTEGER,
41+
content: ui.text("A"),
42+
}),
43+
ui.layer({
44+
id: "b",
45+
zIndex: Number.MAX_SAFE_INTEGER,
46+
content: ui.text("B"),
47+
}),
48+
]);
49+
50+
const res = renderer.submitFrame(
51+
() => view(),
52+
undefined,
53+
{ cols: 20, rows: 6 },
54+
defaultTheme,
55+
noRenderHooks(),
56+
);
57+
assert.ok(res.ok);
58+
59+
const internal = renderer as unknown as {
60+
layerRegistry: {
61+
getAll: () => readonly Readonly<{ id: string; zIndex: number }>[];
62+
};
63+
};
64+
const layers = internal.layerRegistry.getAll();
65+
66+
const a = layers.find((l) => l.id === "a");
67+
const b = layers.find((l) => l.id === "b");
68+
assert.ok(a);
69+
assert.ok(b);
70+
71+
// Should not overflow safe integer range (JS precision would collapse ordering).
72+
assert.ok(Number.isSafeInteger(a.zIndex));
73+
assert.ok(Number.isSafeInteger(b.zIndex));
74+
assert.ok(a.zIndex <= Number.MAX_SAFE_INTEGER);
75+
assert.ok(b.zIndex <= Number.MAX_SAFE_INTEGER);
76+
77+
// Later children should render on top when clamped to the same base z-index.
78+
assert.ok(a.zIndex < b.zIndex);
79+
80+
const scale = 1_000_000;
81+
const maxBase = Math.floor((Number.MAX_SAFE_INTEGER - (scale - 1)) / scale);
82+
assert.equal(Math.trunc(a.zIndex / scale), maxBase);
83+
assert.equal(Math.trunc(b.zIndex / scale), maxBase);
84+
});
85+
});

packages/core/src/app/widgetRenderer.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,23 @@ function isI32NonNegative(n: number): boolean {
220220
return Number.isInteger(n) && n >= 0 && n <= 2147483647;
221221
}
222222

223+
const LAYER_ZINDEX_SCALE = 1_000_000;
224+
const LAYER_ZINDEX_MAX_BASE = Math.floor(
225+
(Number.MAX_SAFE_INTEGER - (LAYER_ZINDEX_SCALE - 1)) / LAYER_ZINDEX_SCALE,
226+
);
227+
228+
function clampInt(v: number, min: number, max: number): number {
229+
if (v < min) return min;
230+
if (v > max) return max;
231+
return v;
232+
}
233+
234+
function encodeLayerZIndex(baseZ: number | null, overlaySeq: number): number {
235+
if (baseZ === null) return overlaySeq;
236+
const clampedBaseZ = clampInt(baseZ, -LAYER_ZINDEX_MAX_BASE, LAYER_ZINDEX_MAX_BASE);
237+
return clampedBaseZ * LAYER_ZINDEX_SCALE + overlaySeq;
238+
}
239+
223240
const EMPTY_ROUTING: RoutingResult = Object.freeze({});
224241
const EMPTY_STRING_ARRAY: readonly string[] = Object.freeze([]);
225242
const ROUTE_RENDER: WidgetRoutingOutcome = Object.freeze({ needsRender: true });
@@ -2188,7 +2205,7 @@ export class WidgetRenderer<S> {
21882205
typeof p.zIndex === "number" && Number.isFinite(p.zIndex)
21892206
? Math.trunc(p.zIndex)
21902207
: null;
2191-
const zIndex = baseZ !== null ? baseZ * 1_000_000 + overlaySeq++ : overlaySeq++;
2208+
const zIndex = encodeLayerZIndex(baseZ, overlaySeq++);
21922209
const modal = p.modal === true;
21932210
const canClose = p.closeOnEscape !== false;
21942211
this._pooledCloseOnEscape.set(id, canClose);
@@ -2390,7 +2407,7 @@ export class WidgetRenderer<S> {
23902407
typeof p.zIndex === "number" && Number.isFinite(p.zIndex)
23912408
? Math.trunc(p.zIndex)
23922409
: null;
2393-
const zIndex = baseZ !== null ? baseZ * 1_000_000 + overlaySeq++ : overlaySeq++;
2410+
const zIndex = encodeLayerZIndex(baseZ, overlaySeq++);
23942411
const modal = p.modal === true;
23952412
const canClose = p.closeOnEscape !== false;
23962413
this._pooledCloseOnEscape.set(id, canClose);

packages/core/src/widgets/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,10 @@ export type DropdownProps = Readonly<{
619619
export type LayerProps = Readonly<{
620620
id: string;
621621
key?: string;
622-
/** Z-index for layer ordering (higher = on top). Default is insertion order. */
622+
/**
623+
* Z-index for layer ordering (higher = on top). Default is insertion order.
624+
* Values are truncated to integers and clamped to `±9,007,199,253` for deterministic ordering.
625+
*/
623626
zIndex?: number;
624627
/** Backdrop to render behind content. */
625628
backdrop?: BackdropStyle;

0 commit comments

Comments
 (0)