Skip to content

web target: mutating a module-level array element (e.g. counter[0]++) does not persist across calls #1993

Description

@TheHypnoo

Summary

On --target web / --target wasm, mutating an element of a module-level array — counter[0]++, counter[0] = counter[0] + 1, counter.push(x) — does not persist across function calls. Inside any single call, subsequent reads of the same index return the original value.

This is the same shape as the just-fixed #1980 / #1983 (module-level let update), but for arrays. The standard workaround floated for that issue — "replace the let with a one-element array" — therefore does not actually work on web, even with the let-update fix in place.

Reproduction

import { App, Canvas, onFrame, onAppKeyDown } from "perry/ui";

const canvas = Canvas(400, 200);

// Workaround for #1992 — force keyboard listener install. Not relevant to this
// bug but needed if you want to also see isKeyDown working.
onAppKeyDown((_k: number, _m: number, _r: boolean) => 0);

const counter: number[] = [0];

function frame(_t: number, _dt: number): number {
  counter[0]++;
  // Log every 30 frames so console isn't drowned.
  if (counter[0] % 30 === 0) {
    console.log("frame#" + counter[0]);
  }
  canvas.setFillColor(0, 0, 0, 1); canvas.fillRect(0, 0, 400, 200);
  canvas.setFillColor(1, 1, 1, 1); canvas.setFont("16px monospace");
  canvas.fillText("counter[0] = " + counter[0], 12, 40);
  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.

Expected: the on-screen text and the periodic log show counter[0] growing (1, 2, 3, …, with a log at 30, 60, 90, …).
Actual: every log says frame#0. The on-screen text stays at counter[0] = 0. The log fires every frame, because counter[0] % 30 === 0 reads the (apparently stale) value 0 every time.

A representative slice of the console after a fresh page load:

[14:46:09] frame#0
[14:46:09] frame#0
[14:46:09] frame#0
[14:46:09] frame#0
[14:46:09] frame#0
... (11 lines, all "frame#0")

If counter[0]++ were taking effect within the same call, the first log would be frame#1, not frame#0.

Suspected root cause

Same family as the fixed #1980: perry-codegen-wasm likely lowers the read-modify-write of an array element to a sequence that doesn't write the new value back through the indexed write path. Either:

  • The element load returns a stale cached value, or
  • The ++ operates on a local copy and the indexed array_set never fires, or
  • The indexed array_set fires but reads continue to hit an unwritten slot.

(The fix that landed for #1980 covered the let update lowering. The same lowering bug almost certainly exists for ArraySubscript ++ / compound assignment.)

Workarounds tried

  • counter[0] = counter[0] + 1 — same behaviour, stale read.
  • Wrapping the counter inside an object property: const state = { n: 0 }; state.n++works, the property update persists. So the bug is specifically in array-indexed updates, not all mutable refs.

So today the only reliable mutable-state shape on web is an object with named properties; arrays-of-ints don't work.

Impact

Every web app that uses a module-level array as a mutable container — counters, accumulators, frame-loop bookkeeping, ring buffers, command queues, the "is initialized" flag. The #1980 writeup explicitly recommended this shape as the workaround, which it isn't on web.

Metadata

Metadata

Assignees

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