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
22 changes: 7 additions & 15 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,13 @@ async function captureWidget(directory: string, options: CaptureOptions): Promis
}

warnings.push(...(nativeResult.warnings ?? []));
// The runtime appends capture-only warnings here (see
// runtime/src/capture_warnings.zig): conditions the pixels show but do not
// explain, such as a canvas whose commands were drawn for another size.
const runtimeWarningsPath = join(captureRoot, "warnings.txt");
if (existsSync(runtimeWarningsPath)) {
warnings.push(...readFileSync(runtimeWarningsPath, "utf8").split("\n").map((line) => line.trim()).filter((line) => line.length > 0));
}
const nativeRenderer = nativeResult.renderer;
const nativeOutput = nativeResult.output;
const nativePending = nativeResult.pending;
Expand Down Expand Up @@ -2849,26 +2856,11 @@ function validateSource(project: SourceProject): string[] {
const available = project.fonts.length === 0 ? "no bundled fonts were found next to widget.tsx" : `available bundled names: ${[...new Set(project.fonts.flatMap((font) => [font.stem, font.family]))].join(", ")}`;
classErrors.add(`Unknown bundled font "${compiled.fontFamily}"; ${available}`);
}
if (tag === "canvas") {
const sizes = compiled as Record<string, unknown>;
for (const axis of ["width", "height"] as const) {
const percent = sizes[axis === "width" ? "widthPercent" : "heightPercent"] !== undefined;
if (percent || sizes[axis] === undefined) {
const shape = percent ? `a percentage ${axis}` : `no ${axis}`;
classErrors.add(
`CanvasNeedsExplicitSize: <canvas> has ${shape}. A canvas has no intrinsic size, so inside a content-sized container a percentage resolves against 0 and every draw silently no-ops (ctx.${axis} === 0). Fix: give the canvas an explicit pixel ${axis}, e.g. ${axis === "width" ? "w-[312px]" : "h-[71px]"}.`,
);
}
}
}
}
catch (error) { classErrors.add(error instanceof UtilityError ? error.message : String(error)); }
for (const error of classErrors) errors.push(locationMessage(sourceFile, classAttribute, error));
}
}
if (tag === "canvas" && !classAttribute) {
errors.push(locationMessage(sourceFile, node, `CanvasNeedsExplicitSize: <canvas> has no class. A canvas has no intrinsic size and draws nothing without one; give it explicit pixel dimensions, e.g. class="w-[312px] h-[71px]".`));
}
if (tag === "canvas") {
const opacityAncestor = canvasOpacityAncestor(node, context);
if (opacityAncestor) {
Expand Down
21 changes: 14 additions & 7 deletions cli/test/canvas-size.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,31 @@ export default widget({ name: "Canvas Size", size: [320, 200] }, () => (
`;
}

test("check rejects a canvas with a percentage width", () => {
test("check accepts a canvas sized by layout with a percentage width", () => {
const { root, widget } = fixture(source(`<canvas class="w-full h-[71px]" fps={0} onFrame={() => {}} />`));
try {
const checked = runCli(root, "check", widget);
assert.equal(checked.status, 1);
assert.match(checked.stderr, /CanvasNeedsExplicitSize: <canvas> has a percentage width/);
assert.match(checked.stderr, /w-\[312px\]/);
assert.equal(checked.status, 0, checked.stderr);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test("check accepts a canvas that grows into its row", () => {
const { root, widget } = fixture(source(`<row class="w-full"><canvas class="w-0 grow h-[8px]" fps={0} onFrame={() => {}} /></row>`));
try {
const checked = runCli(root, "check", widget);
assert.equal(checked.status, 0, checked.stderr);
} finally {
rmSync(root, { recursive: true, force: true });
}
});

test("check rejects a canvas with no class at all", () => {
test("check accepts a canvas with no class at all", () => {
const { root, widget } = fixture(source(`<canvas fps={0} onFrame={() => {}} />`));
try {
const checked = runCli(root, "check", widget);
assert.equal(checked.status, 1);
assert.match(checked.stderr, /CanvasNeedsExplicitSize: <canvas> has no class/);
assert.equal(checked.status, 0, checked.stderr);
} finally {
rmSync(root, { recursive: true, force: true });
}
Expand Down
5 changes: 5 additions & 0 deletions docs/agent-widget-capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ timeout.
`provenance` records the Weaver and Native SDK commits plus the exact bundled
JavaScript hash. A dirty checkout is allowed but is called out in `warnings`.

`warnings` also carries runtime observations that the pixels show but cannot
explain. `CanvasDrewForStaleLayout` names a canvas whose last `onFrame` ran
for a different size than layout finally gave it; a blank or clipped canvas
with that line is a framework reproduction, not a widget bug.

After building the runtime, run the repository acceptance corpus with
`npm run test:capture`. It checks non-flat pixels, semantic state changes,
provider failure and fixture behavior, ambiguous-target candidates, artifact
Expand Down
32 changes: 32 additions & 0 deletions runtime/src/capture_warnings.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//! A capture warning is a condition the PNG shows but cannot explain: the
//! render succeeded, yet something an author would call broken is visible,
//! and the receipt should say why. Under `weaver capture` each warning is
//! appended to `<WEAVER_CAPTURE_STATE_ROOT>/warnings.txt`; the CLI merges the
//! lines into the receipt's `warnings`. Ordinary widgets never call this.
const std = @import("std");

var io: ?std.Io = null;
var capture_path: []const u8 = &.{};

pub fn initCaptureSink(runtime_io: std.Io, path: []const u8) void {
io = runtime_io;
capture_path = path;
}

pub fn report(comptime format: []const u8, args: anytype) void {
std.log.warn(format, args);
const runtime_io = io orelse return;
var buffer: [2048]u8 = undefined;
var writer = std.Io.Writer.fixed(&buffer);
writer.print(format ++ "\n", args) catch return;
appendLine(runtime_io, capture_path, writer.buffered()) catch |err| {
std.log.warn("capture warnings file unavailable: {s}; path={s}", .{ @errorName(err), capture_path });
};
}

fn appendLine(runtime_io: std.Io, path: []const u8, line: []const u8) !void {
var file = try std.Io.Dir.cwd().createFile(runtime_io, path, .{ .read = true, .truncate = false });
defer file.close(runtime_io);
const stat = try file.stat(runtime_io);
try file.writePositionalAll(runtime_io, line, stat.size);
}
53 changes: 52 additions & 1 deletion runtime/src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const storage_mod = @import("storage.zig");
const windows_monitor = if (builtin.os.tag == .windows) @import("platform/windows_monitor.zig") else struct {};
const tree_mod = @import("tree.zig");
const widget_diagnostic = @import("widget_diagnostic.zig");
const capture_warnings = @import("capture_warnings.zig");
const widget_log = @import("widget_log.zig");

comptime {
Expand Down Expand Up @@ -120,6 +121,10 @@ pub const Msg = union(enum) {
right_press: native_sdk.canvas.WidgetPressEvent,
slider: tree_mod.NodeId,
canvas_frame: u64,
/// A presented frame laid a canvas out at a size the SDK has not been
/// told yet. The dispatch itself carries the fix: `syncNativeState` runs
/// before `update` and fires `onCanvasResize`.
canvas_layout: void,
external_wake: struct { provider: bool, dev_reload: bool },
frame_moved: geometry_mod.Saved,
};
Expand Down Expand Up @@ -245,6 +250,7 @@ fn update(model: *Model, msg: Msg, effects: *Effects) void {
.on_fire = Effects.timerMsg(.timer),
});
},
.canvas_layout => {},
.canvas_frame => |timestamp_ns| {
if (model.provider_poll_interval_ms <= 33) {
dispatchProviderFrames(model, effects, "canvas frame");
Expand Down Expand Up @@ -617,6 +623,7 @@ fn onFrame(model: *const Model, frame: native_sdk.platform.GpuFrame) ?Msg {
}
}
const engine = model.engine orelse return null;
if (canvasLayoutStale(model, frame)) return Msg{ .canvas_layout = {} };
const canvas_clock = engine.hasCanvasFrames();
if (!canvas_clock) return null;
// Hybrid presentation now rejects a clean completion revision before it
Expand All @@ -637,6 +644,29 @@ fn onFrame(model: *const Model, frame: native_sdk.platform.GpuFrame) ?Msg {
/// that frame boundary into the ordinary update/rebuild path. Static widgets
/// therefore hot-swap without borrowing a GPU completion from animation,
/// input, or resize.
/// Layout sizes a canvas after the retained tree is built, but the SDK learns
/// that size only through `syncNativeState`, which Native runs at the start of
/// the next dispatch. A widget with nothing else to dispatch (a fixed-clock
/// capture, a live widget before its first event) would draw at its
/// class-declared size forever. So a presented frame whose canvas layout
/// disagrees with what the tree last published requests exactly one dispatch.
/// The comparison mirrors `syncNativeState` so the two agree after that sync.
fn canvasLayoutStale(model: *const Model, frame: native_sdk.platform.GpuFrame) bool {
const runtime = diagnostic_runtime orelse return false;
const layout = runtime.canvasWidgetLayout(frame.window_id, frame.label) catch return false;
for (&model.tree.canvases, 0..) |*canvas_state, index| {
if (!model.tree.canvas_occupied.isSet(index) or canvas_state.owner == 0) continue;
const widget_id = native_sdk.canvas.globalWidgetId(.stack, native_sdk.canvas.uiKey(canvas_state.owner));
for (layout.nodes) |layout_node| {
if (layout_node.widget.id != widget_id) continue;
const laid_out = layout_node.frame.normalized();
if (@max(laid_out.width, 0) != canvas_state.layout_width or @max(laid_out.height, 0) != canvas_state.layout_height) return true;
break;
}
}
return false;
}

fn onFrameRequested(_: *const Model) ?Msg {
const provider = provider_wake_pending.swap(false, .acq_rel);
const dev = dev_reload_pending.swap(false, .acq_rel);
Expand Down Expand Up @@ -720,7 +750,10 @@ fn projectSameViewUpdate(
);

switch (msg) {
.canvas_frame => {},
// Both carry fresh canvas commands that the retained tree must show
// without a rebuild: a draw tick, or the redraw that follows a layout
// size reaching the SDK.
.canvas_frame, .canvas_layout => {},
else => return true,
}

Expand Down Expand Up @@ -1392,6 +1425,7 @@ pub fn main(init: std.process.Init) !void {
// also go to a file the CLI copies into the capture receipt.
if (capture_state_root) |root| {
widget_diagnostic.initCaptureSink(init.io, try std.fs.path.join(allocator, &.{ root, "diagnostic.txt" }));
capture_warnings.initCaptureSink(init.io, try std.fs.path.join(allocator, &.{ root, "warnings.txt" }));
}
std.log.info("widget runtime starting pid={d}{s}", .{ platform.currentProcessId(), if (dev) " dev=true" else "" });
var storage = try storage_mod.Store.init(init.io, allocator, data_root, loaded.manifest.name);
Expand Down Expand Up @@ -1674,6 +1708,23 @@ fn validateCapture(context: *anyopaque) !void {
if (app_state.model.engine) |engine| {
if (engine.renderFailed()) return error.CaptureWidgetFailed;
}
reportCanvasLayoutMismatches(&app_state.model);
}

/// A canvas whose retained commands were published for a different layout
/// size than the one it now has is showing stale geometry: typically a blank
/// or clipped bar. The PNG shows the symptom; this warning names the cause so
/// the receipt alone is enough to act on. With `canvasLayoutStale` in place
/// no correct widget reaches it; it is a tripwire for a regression there.
fn reportCanvasLayoutMismatches(model: *const Model) void {
for (&model.tree.canvases, 0..) |*canvas_state, index| {
if (!model.tree.canvas_occupied.isSet(index) or canvas_state.owner == 0 or canvas_state.command_count == 0) continue;
if (canvas_state.command_layout_width == canvas_state.layout_width and canvas_state.command_layout_height == canvas_state.layout_height) continue;
capture_warnings.report(
"CanvasDrewForStaleLayout: canvas node {d} drew its last frame for a {d}x{d} layout but layout now gives it {d}x{d}; onFrame did not run after layout sized it",
.{ canvas_state.owner, canvas_state.command_layout_width, canvas_state.command_layout_height, canvas_state.layout_width, canvas_state.layout_height },
);
}
}

fn loadCaptureProviderFixture(
Expand Down
13 changes: 13 additions & 0 deletions sdk/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -833,3 +833,16 @@ Click-to-seek uses the event's normalized local `u` coordinate. The handler
clamps `event.u` to `[0, 1]`, multiplies by `durationMs`, and passes that
absolute value to `seek(ms)`. The SDK owns whole-millisecond normalization.
Click-only seek is the contracted example behavior; dragging is not implied.


## Canvas layout sizing amendment

A `<canvas>` is sized by layout like every other element. Explicit pixel
sizes, `w-full`, fractions, and `grow` all work. Inside `onFrame`,
`ctx.width` and `ctx.height` are the laid-out size, and `onFrame` runs again
whenever layout changes that size, including the first layout after mount. The
class-declared size is only the guess used for the very first draw; a
re-render never shrinks the draw size back to it. `weaver check` no longer
requires explicit pixel dimensions on a canvas (`CanvasNeedsExplicitSize` is
retired). A capture whose canvas commands were drawn for a different size than
the canvas's layout reports `CanvasDrewForStaleLayout` in `warnings`.
24 changes: 20 additions & 4 deletions sdk/src/reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,14 @@ interface CanvasBinding {
timerId: number;
surfaceClock: boolean;
surfaceClockGeneration?: object;
/** Draw size. Starts as the class-declared size and becomes the layout size once the runtime reports it. */
width: number;
height: number;
/** The class-declared size from the last render; only a guess until `layoutSized`. */
declaredWidth: number;
declaredHeight: number;
/** Set once `onCanvasResize` has delivered a layout size; from then on re-renders must not shrink the draw size back to the declared one. */
layoutSized: boolean;
lastT?: number;
nextT?: number;
nativeTimestampStarted?: boolean;
Expand All @@ -217,6 +223,7 @@ const colorCache: Record<string, number> = Object.create(null) as Record<string,
native.onCanvasResize((id, width, height) => runWidgetCallback("canvas resize callback", () => {
const binding = canvases.get(id);
if (!binding || !Number.isFinite(width) || !Number.isFinite(height) || width < 0 || height < 0) return;
binding.layoutSized = true;
if (binding.width === width && binding.height === height) return;
binding.width = width;
binding.height = height;
Expand Down Expand Up @@ -776,17 +783,26 @@ function updateCanvasBinding(id: number, onFrame: (ctx: CanvasCtx, frame: Canvas
if (!binding) {
binding = {
onFrame, fps, timerId: 0, surfaceClock: false, width, height,
declaredWidth: width, declaredHeight: height, layoutSized: false,
batch: new Float64Array(MAX_CANVAS_WIRE_VALUES), batchLength: 0, active: false,
ctx: undefined as unknown as CanvasCtx,
};
binding.ctx = createCanvasContext(binding);
canvases.set(id, binding);
} else {
const sizeChanged = binding.width !== width || binding.height !== height;
// The declared size is a guess until layout reports the real one. After
// that, a re-render must keep the layout size: the runtime refires
// onCanvasResize only when layout actually moves, so overwriting it here
// would leave a `grow` or `w-full` canvas drawing at its declared 0 forever.
const declaredChanged = binding.declaredWidth !== width || binding.declaredHeight !== height;
binding.declaredWidth = width;
binding.declaredHeight = height;
binding.onFrame = onFrame;
binding.width = width;
binding.height = height;
if (sizeChanged) binding.ctx = createCanvasContext(binding);
if (declaredChanged && !binding.layoutSized) {
binding.width = width;
binding.height = height;
binding.ctx = createCanvasContext(binding);
}
}
if (rateChanged && binding.timerId !== 0) {
native.clearInterval(binding.timerId);
Expand Down
56 changes: 56 additions & 0 deletions sdk/test/reconciler.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -722,3 +722,59 @@ test("hot swap captures and seeds root hook slots only when every slot type matc
const malformed = await runHotSwapFixture({ seed: "not-json" });
assert.equal(malformed.fixtureAccepted, false);
});

test("a canvas keeps its layout size across re-renders", async () => {
const source = `
import { h, useState, widget } from "./src/index.ts";
widget({ name: "Canvas layout size fixture", size: [240, 80] }, () => {
const [tick, setTick] = useState(0);
globalThis.rerender = () => setTick((value) => value + 1);
return h("row", { class: "w-full gap-2" },
h("canvas", {
class: "w-0 grow h-[8px]",
onFrame(ctx) { globalThis.drawSizes.push([ctx.width, ctx.height]); ctx.clear(); },
}),
h("text", null, String(tick)),
);
});
`;
const output = await build({
stdin: { contents: source, resolveDir: fileURLToPath(new URL("..", import.meta.url)), sourcefile: "canvas-layout-size-fixture.ts" },
bundle: true, format: "iife", platform: "neutral", write: false,
});
let id = 0;
let canvasId = 0;
let resize;
const errors = [];
const native = {
...isolatedNative(),
createNode(type) { id += 1; if (type === "canvas") canvasId = id; return id; },
reportError(...args) { errors.push(args); },
onCanvasResize(callback) { resize = callback; },
setCanvasCommands() {},
};
const context = { native, drawSizes: [] };
vm.runInNewContext(output.outputFiles[0].text, context);
assert.deepEqual(errors, []);

// The first draw can only use the class-declared size: `w-0 grow` is 0 wide until layout runs.
assert.deepEqual(Array.from(context.drawSizes.at(-1)), [0, 8]);

// Layout reports the real size; the canvas redraws at it.
resize(canvasId, 181, 8);
assert.deepEqual(Array.from(context.drawSizes.at(-1)), [181, 8]);

// A re-render must not shrink the draw size back to the declared 0.
const drawsBefore = context.drawSizes.length;
context.rerender();
await Promise.resolve();
assert.ok(context.drawSizes.length > drawsBefore, "re-render redraws the one-shot canvas");
assert.deepEqual(Array.from(context.drawSizes.at(-1)), [181, 8]);

// Layout moving again is still honored.
resize(canvasId, 150, 8);
assert.deepEqual(Array.from(context.drawSizes.at(-1)), [150, 8]);
context.rerender();
await Promise.resolve();
assert.deepEqual(Array.from(context.drawSizes.at(-1)), [150, 8]);
});
Loading
Loading