Summary
On --target web / --target wasm, mutating a module-level let (e.g. ticks++) appears to have no effect: subsequent reads of the same variable — even within the same function body, immediately after the ++ — return the pre-increment value. The bug surfaces strongly when used with onFrame, but it isn't onFrame-specific: it appears to be a general module-level mutable lowering issue.
Reproduction
import { App, Canvas, onFrame } from "perry/ui";
const canvas = Canvas(400, 200);
let ticks = 0;
function frame(_t: number, _dt: number): number {
ticks++;
const x = ticks % 400; // expected: grows by 1 each frame
canvas.setFillColor(0, 0, 0, 1);
canvas.fillRect(0, 0, 400, 200);
canvas.setFillColor(1, 1, 1, 1);
canvas.fillRect(x, 80, 20, 20); // bar should sweep across the canvas
onFrame(frame);
return 0;
}
onFrame(frame);
App({ title: "repro", width: 400, height: 200, body: canvas });
Compile with perry compile repro.ts -o repro --target web, serve over HTTP, open in a modern browser.
Expected: a white bar sweeps left-to-right at ~60 px/s.
Actual: the bar stays frozen at x = 0..19, i.e. ticks reads back as 0 even after ticks++ ran inside the same call.
Diagnostic evidence
In a working browser session (after applying the workaround for the void-closure crash issue):
- Hooking
window.requestAnimationFrame confirms the bridge fires the callback ~130 times per second.
- Hooking
callWasmClosure and logging the WASM args for the frame closure shows t does advance correctly (39140 → 39148 → 39156 → …).
- Despite ~130 invocations per second of
frame, sampling the canvas at any point shows the white bar still at x = 0..19, i.e. ticks % 400 === 0 at render time.
If ticks++ were taking effect within the call, the first frame would draw the bar at x = 1, not x = 0. So this isn't only "module-level state doesn't persist across calls" — it's that the post-increment isn't visible to the immediate next read of the same variable in the same body.
Suspected root cause
perry-codegen-wasm likely lowers module-level let to a WASM global, copies it into a function-local on entry, mutates the local, but doesn't issue a global.set either after the mutation or before the next read. That would explain both:
- The within-function read returning the stale value (if reads always go through the global).
- The mutation not persisting across calls (if reads sometimes go through the local but writes never go to the global).
(The same diagnosis fits if reads go through the local but the ++ is lowered to read-global → add-1 → discard, never touching the local either.)
Workarounds that confirm the diagnosis
- Replace the
let with a one-element array: const ticksRef: number[] = [0]; ticksRef[0]++; const x = ticksRef[0] % 400; — works.
- Pass the variable via the closure capture (wrap the registration in an IIFE): also works.
Both confirm the bug is specific to the lowering path for module-level let reads/writes, not to onFrame, closures, or the Canvas API.
Impact
Any non-trivial WASM app that uses module-level mutable state: counters, accumulators, game state references, "is initialized" flags, frame loop bookkeeping. Idiomatic TypeScript that works on native targets silently freezes on web.
Summary
On
--target web/--target wasm, mutating a module-levellet(e.g.ticks++) appears to have no effect: subsequent reads of the same variable — even within the same function body, immediately after the++— return the pre-increment value. The bug surfaces strongly when used withonFrame, but it isn't onFrame-specific: it appears to be a general module-level mutable lowering issue.Reproduction
Compile with
perry compile repro.ts -o repro --target web, serve over HTTP, open in a modern browser.Expected: a white bar sweeps left-to-right at ~60 px/s.
Actual: the bar stays frozen at
x = 0..19, i.e.ticksreads back as0even afterticks++ran inside the same call.Diagnostic evidence
In a working browser session (after applying the workaround for the void-closure crash issue):
window.requestAnimationFrameconfirms the bridge fires the callback ~130 times per second.callWasmClosureand logging the WASM args for the frame closure showstdoes advance correctly (39140 → 39148 → 39156 → …).frame, sampling the canvas at any point shows the white bar still atx = 0..19, i.e.ticks % 400 === 0at render time.If
ticks++were taking effect within the call, the first frame would draw the bar atx = 1, notx = 0. So this isn't only "module-level state doesn't persist across calls" — it's that the post-increment isn't visible to the immediate next read of the same variable in the same body.Suspected root cause
perry-codegen-wasmlikely lowers module-levelletto a WASM global, copies it into a function-local on entry, mutates the local, but doesn't issue aglobal.seteither after the mutation or before the next read. That would explain both:(The same diagnosis fits if reads go through the local but the
++is lowered to read-global → add-1 → discard, never touching the local either.)Workarounds that confirm the diagnosis
letwith a one-element array:const ticksRef: number[] = [0]; ticksRef[0]++; const x = ticksRef[0] % 400;— works.Both confirm the bug is specific to the lowering path for module-level
letreads/writes, not to onFrame, closures, or the Canvas API.Impact
Any non-trivial WASM app that uses module-level mutable state: counters, accumulators, game state references, "is initialized" flags, frame loop bookkeeping. Idiomatic TypeScript that works on native targets silently freezes on web.