Skip to content

Fix state mutation via Stateful.getter return values (fixes #870) - #883

Open
unusdon wants to merge 1 commit into
Telegram-Mini-Apps:masterfrom
unusdon:fix/stateful-getter-ref-leak
Open

Fix state mutation via Stateful.getter return values (fixes #870)#883
unusdon wants to merge 1 commit into
Telegram-Mini-Apps:masterfrom
unusdon:fix/stateful-getter-ref-leak

Conversation

@unusdon

@unusdon unusdon commented Aug 21, 2026

Copy link
Copy Markdown

Fixes #870.

What changed

viewport.safeAreaInsets() and viewport.contentSafeAreaInsets() (and every other stateful getter that reads a nested object) hand out a direct reference to the internal state — a caller doing safeAreaInsets.top += 100 silently mutates the source, and every subsequent read observes the mutation:

const insets = viewport.safeAreaInsets();
insets.top += 100;
viewport.safeAreaInsets().top; // 143 (was 43) — leaked

Fix

Stateful.getter now returns a frozen shallow copy of plain objects and arrays. Primitives and class instances are returned as-is:

getter<K extends keyof S>(key: K): Computed<S[K]> {
  return computed(() => frozenClone(this._state()[key]));
}

function frozenClone<T>(value: T): T {
  if (Array.isArray(value)) return Object.freeze(value.slice()) as unknown as T;
  if (value !== null && typeof value === 'object') {
    const proto = Object.getPrototypeOf(value);
    if (proto === null || proto === Object.prototype) {
      return Object.freeze({ ...(value as object) }) as T;
    }
  }
  return value;
}

Why freeze, not just clone

@tma.js/signals's computed() memoises its function output — a bare shallow copy would be produced once and then handed to every subsequent read (same ref every time). With Object.freeze on the cached copy, mutation attempts throw in strict mode rather than silently corrupting state.

Why not copy class instances

Spread strips prototypes (Map{}, Date{}, custom classes lose their methods). That's a bigger behaviour change than the ref-leak fix warrants. Class instances round-trip by reference; the SafeAreaInsets case is a plain object, which the fix covers.

Tests

Added packages/sdk/src/composables/Stateful.test.ts — 4 tests covering:

  • Nested-object reads are frozen; mutation throws TypeError
  • Stale reads stay stale (state update doesn't retroactively mutate a captured copy)
  • Array reads are frozen; push throws TypeError
  • Primitives are returned unchanged

All 4 pass + full @tma.js/sdk suite (782 tests) still green. Typecheck + lint clean on touched files (there's a pre-existing max-len warning in src/errors.ts that's not mine).

Changeset

.changeset/fix-stateful-getter-ref-leak.md@tma.js/sdk patch.

…Mini-Apps#870)

viewport.safeAreaInsets() and viewport.contentSafeAreaInsets() (and every
other stateful getter that reads a nested object) hand out a direct reference
to the internal state — a caller doing 'safeAreaInsets.top += 100' silently
mutates the source and every subsequent read observes the mutation.

Fix: Stateful.getter now returns a *frozen* shallow copy of plain objects
and arrays. Freeze is used instead of a per-read copy because @tma.js/signals
computed() memoises its function output — a copy alone would be produced
once and then handed to every subsequent read. With the freeze in place, a
consumer attempting to mutate the returned object throws in strict mode
rather than silently corrupting internal state.

Class instances (Date, Map, Set, custom classes) round-trip by reference —
spread would strip their prototypes, which would be a bigger behaviour change
than the ref-leak fix warrants.

Regression covered by packages/sdk/src/composables/Stateful.test.ts: nested
object freeze, array freeze, stale-read isolation, primitives passthrough.

All 782 existing @tma.js/sdk tests pass, plus 4 new tests. Typecheck +
lint clean on the touched files (a pre-existing max-len warning in
src/errors.ts is unrelated).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Potential state mutation in viewport safe area methods due to object reference leakage

1 participant