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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ All notable changes to Rezi are documented in this file.
The format is based on Keep a Changelog and the project follows Semantic Versioning.

## [Unreleased]

### Bug Fixes

- **core/composition + hooks**: Composite widgets now use a layout-transparent default wrapper, animation hooks share a frame driver, transition/orchestration hooks stop relying on stringified config signatures, `useAnimatedValue` transition playback preserves progress across pause/resume, `useStagger` restarts on same-length item replacement, and streaming hook reconnect delays clamp away tight-loop reconnects.
- **core/runtime + perf**: Hardened lifecycle start/stop/fatal edges, sync frame follow-up scheduling, focus/layer callback failure handling, focus container metadata/state publication, and perf ring-buffer rollover stats.
- **core/layout + constraints**: Constraint sibling aggregation is now same-parent scoped, hidden `display: false` layout widgets are removed from runtime interaction metadata even without an active constraint graph, deep parent-dependent chains settle fully in the first committed frame, box intrinsic sizing ignores absolute children, and unsupported absolute-position usage now emits deterministic dev warnings.

### Documentation

- **docs/guide**: Synced composition, animation, and hook reference docs with the current hook surface, easing presets, callback semantics, viewport availability, and stable parser examples for streaming hooks.
- **docs/lifecycle**: Corrected `onEvent(...)` examples, fatal payload fields, hot-reload state guarantees, and `run()` behavior when signal registration is unavailable.
- **docs/layout + constraints**: Aligned recipes and guides with actual support boundaries for spacing, absolute positioning, `display`, and same-parent sibling aggregation semantics.

## [0.1.0-alpha.57] - 2026-03-06

### Documentation
Expand Down
9 changes: 5 additions & 4 deletions docs/guide/lifecycle-and-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Use `run()` for batteries-included lifecycle wiring in Node/Bun apps:
- `run()` wraps `start()`
- registers `SIGINT` / `SIGTERM` / `SIGHUP`
- on signal: `stop()` then `dispose()` best-effort, then exits with code `0`
- if signal handlers cannot be registered in the current runtime, `run()` still starts the app and resolves once startup completes

Use `start()` directly when you need manual signal/process control.

Expand Down Expand Up @@ -60,8 +61,8 @@ Set one mode before calling `start()`. Switching modes while running is rejected
Rezi supports development-time view hot swapping without process restarts.

- `app.view(fn)` remains the initial setup API (`Created`/`Stopped` states)
- `app.replaceView(fn)` swaps the active widget view while `Running`
- `app.replaceRoutes(routes)` swaps route definitions for route-managed apps while `Running`
- `app.replaceView(fn)` swaps the active widget view while `Running`, and also updates the configured widget view in `Created`/`Stopped`
- `app.replaceRoutes(routes)` swaps route definitions for route-managed apps while `Running`, and also updates the configured route table in `Created`/`Stopped`

When a new view function is applied, Rezi keeps:

Expand Down Expand Up @@ -170,7 +171,7 @@ app.keys({

```typescript
const unsubscribe = app.onEvent((ev) => {
if (ev.kind === "fatal") console.error(ev.code, ev.message);
if (ev.kind === "fatal") console.error(ev.code, ev.detail);
});
// later: unsubscribe()
```
Expand All @@ -194,7 +195,7 @@ In addition, these actions also flow through `app.onEvent(...)`, which enables
cross-cutting middleware, logging, analytics, and undo stacks.

```typescript
app.on("event", (ev) => {
app.onEvent((ev) => {
if (ev.kind === "action" && ev.action === "toggle") {
console.log(`Checkbox ${ev.id} toggled to ${ev.checked}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ describe("commandPalette async fetch contracts", () => {
});

describe("commandPalette escape contracts in layered focus contexts", () => {
test("modal layer with closeOnEscape=false routes Escape to focused command palette", () => {
test("modal layer with closeOnEscape=false keeps Escape owned by the top layer", () => {
const backend = createNoopBackend();
const renderer = new WidgetRenderer<void>({
backend,
Expand Down Expand Up @@ -348,7 +348,7 @@ describe("commandPalette escape contracts in layered focus contexts", () => {
assert.equal(renderer.getFocusedId(), "cp");

renderer.routeEngineEvent(keyEvent(ZR_KEY_ESCAPE));
assert.deepEqual(events, ["palette-close"]);
assert.deepEqual(events, []);
});

test("modal layer with closeOnEscape=true closes layer before palette handler", () => {
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/app/__tests__/eventPump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,41 @@ test("parse failure is fatal protocol error and still releases batch (#60/#63)",
assert.equal(backend.stopCalls, 1);
assert.equal(backend.disposeCalls, 1);
});

test("faulted turn drains remaining batches without double-releasing processed batch", async () => {
const backend = new StubBackend();
const app = createApp({ backend, initialState: 0 });
app.draw((g) => g.clear());

app.onEvent((ev) => {
if (ev.kind === "engine" && ev.event.kind === "text") {
throw new Error("boom");
}
});

await app.start();

let firstReleased = 0;
let secondReleased = 0;
backend.pushBatch(
makeBackendBatch({
bytes: encodeZrevBatchV1({
events: [{ kind: "text", timeMs: 1, codepoint: 65 }],
}),
onRelease: () => firstReleased++,
}),
);
backend.pushBatch(
makeBackendBatch({
bytes: encodeZrevBatchV1({
events: [{ kind: "text", timeMs: 2, codepoint: 66 }],
}),
onRelease: () => secondReleased++,
}),
);

await flushMicrotasks(20);

assert.equal(firstReleased, 1);
assert.equal(secondReleased, 1);
});
61 changes: 61 additions & 0 deletions packages/core/src/app/__tests__/onEventHandlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assert, test } from "@rezi-ui/testkit";
import { ui } from "../../index.js";
import { createApp } from "../createApp.js";
import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js";
import { StubBackend } from "./stubBackend.js";
Expand Down Expand Up @@ -42,3 +43,63 @@ test("onEvent handler ordering + unsubscribe semantics (#80)", async () => {
// It is not called for subsequent events.
assert.deepEqual(calls, ["A", "B", "C", "A", "C"]);
});

test("onEvent handler failure aborts the current batch before later events commit", async () => {
const backend = new StubBackend();
const app = createApp({ backend, initialState: 0 });

const rendered: number[] = [];
app.view((state) => {
rendered.push(state);
return ui.text(`count:${String(state)}`);
});

let applyResizeUpdates = false;
app.onEvent((ev) => {
if (ev.kind !== "engine") return;
if (ev.event.kind === "text") throw new Error("boom");
if (applyResizeUpdates && ev.event.kind === "resize") {
app.update((n) => n + 1);
}
});

const fatals: string[] = [];
app.onEvent((ev) => {
if (ev.kind === "fatal") fatals.push(ev.detail);
});

await app.start();
backend.pushBatch(
makeBackendBatch({
bytes: encodeZrevBatchV1({
events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }],
}),
}),
);
await flushMicrotasks(10);

assert.deepEqual(rendered, [0]);
assert.equal(backend.requestedFrames.length, 1);

backend.resolveNextFrame();
await flushMicrotasks(10);

applyResizeUpdates = true;
backend.pushBatch(
makeBackendBatch({
bytes: encodeZrevBatchV1({
events: [
{ kind: "text", timeMs: 2, codepoint: 65 },
{ kind: "resize", timeMs: 3, cols: 81, rows: 24 },
],
}),
}),
);
await flushMicrotasks(20);

assert.equal(fatals.length, 1);
assert.deepEqual(rendered, [0]);
assert.equal(backend.requestedFrames.length, 1);
assert.equal(backend.stopCalls, 1);
assert.equal(backend.disposeCalls, 1);
});
31 changes: 31 additions & 0 deletions packages/core/src/app/__tests__/onFocusChange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,34 @@ test("onFocusChange unsubscribe stops future callbacks", async () => {

await settleNextFrame(backend);
});

test("onFocusChange handler failure faults immediately before focus-render follow-up", async () => {
const backend = new StubBackend();
const app = createApp({
backend,
initialState: 0,
});

app.view(() =>
ui.column({}, [ui.input({ id: "name", value: "" }), ui.button({ id: "save", label: "Save" })]),
);

const fatals: string[] = [];
app.onEvent((ev) => {
if (ev.kind === "fatal") fatals.push(ev.detail);
});
app.onFocusChange(() => {
throw new Error("focus boom");
});

await app.start();
await pushEvents(backend, [{ kind: "resize", timeMs: 1, cols: 40, rows: 10 }]);
await settleNextFrame(backend);

await pushEvents(backend, [{ kind: "key", timeMs: 2, key: ZR_KEY_TAB, action: "down" }]);

assert.equal(fatals.length, 1);
assert.equal(backend.requestedFrames.length, 1);
assert.equal(backend.stopCalls, 1);
assert.equal(backend.disposeCalls, 1);
});
38 changes: 38 additions & 0 deletions packages/core/src/app/__tests__/syncFrameAckFastPath.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assert, test } from "@rezi-ui/testkit";
import { defineWidget, ui } from "../../index.js";
import { createApp } from "../createApp.js";
import { encodeZrevBatchV1, flushMicrotasks, makeBackendBatch } from "./helpers.js";
import { StubBackend } from "./stubBackend.js";
Expand Down Expand Up @@ -51,3 +52,40 @@ test("sync frame-ack marker allows next render without waiting for frameSettled

app.dispose();
});

test("sync frame-ack still schedules a follow-up frame for effect-driven invalidation", async () => {
const backend = new SyncFrameAckBackend();
const app = createApp({
backend,
initialState: 0,
config: { maxFramesInFlight: 1 },
});

const seenCounts: number[] = [];
const Counter = defineWidget<{ key?: string }>((_props, ctx) => {
const [count, setCount] = ctx.useState(0);
seenCounts.push(count);
ctx.useEffect(() => {
if (count === 0) setCount(1);
}, [count]);
return ui.text(`count:${String(count)}`);
});

app.view(() => Counter({ key: "counter" }));
await app.start();

backend.pushBatch(
makeBackendBatch({
bytes: encodeZrevBatchV1({
events: [{ kind: "resize", timeMs: 1, cols: 80, rows: 24 }],
}),
}),
);

await flushMicrotasks(20);

assert.equal(backend.requestedFrames.length, 2);
assert.deepEqual(seenCounts, [0, 1]);

app.dispose();
});
Loading
Loading