Skip to content

web target: mutating a module-level let (e.g. ticks++) is not reflected in subsequent reads on the same call #1980

Description

@TheHypnoo

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

  1. Replace the let with a one-element array: const ticksRef: number[] = [0]; ticksRef[0]++; const x = ticksRef[0] % 400; — works.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions