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.
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
letupdate), 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
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 atcounter[0] = 0. The log fires every frame, becausecounter[0] % 30 === 0reads the (apparently stale) value 0 every time.A representative slice of the console after a fresh page load:
If
counter[0]++were taking effect within the same call, the first log would beframe#1, notframe#0.Suspected root cause
Same family as the fixed #1980:
perry-codegen-wasmlikely 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:++operates on a local copy and the indexedarray_setnever fires, orarray_setfires but reads continue to hit an unwritten slot.(The fix that landed for #1980 covered the
letupdate lowering. The same lowering bug almost certainly exists forArraySubscript++/ compound assignment.)Workarounds tried
counter[0] = counter[0] + 1— same behaviour, stale read.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
#1980writeup explicitly recommended this shape as the workaround, which it isn't on web.