Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-stateful-getter-ref-leak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tma.js/sdk": patch
---

Fix state mutation via `Stateful.getter` return values (fixes #870). Nested-object reads (e.g. `viewport.safeAreaInsets()`) now return a frozen shallow copy so consumer mutations cannot leak back into the internal state.
77 changes: 77 additions & 0 deletions packages/sdk/src/composables/Stateful.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';

import { Stateful } from './Stateful.js';

interface SafeAreaInsets {
top: number;
right: number;
bottom: number;
left: number;
}

interface State {
safeAreaInsets: SafeAreaInsets;
height: number;
labels: string[];
}

function newStateful() {
return new Stateful<State>({
initialState: {
safeAreaInsets: { top: 43, right: 0, bottom: 34, left: 0 },
height: 800,
labels: ['a', 'b'],
},
onChange: vi.fn(),
});
}

describe('Stateful', () => {
describe('getter (regression for #870)', () => {
it('freezes nested-object reads so consumers cannot mutate them', () => {
const s = newStateful();
const insets = s.getter('safeAreaInsets');

const first = insets();

expect(Object.isFrozen(first)).toBe(true);
expect(() => {
first.top += 100;
}).toThrow(TypeError);
});

it('reads never expose the internal state reference', () => {
const s = newStateful();
const insets = s.getter('safeAreaInsets');

// Even if a caller bypasses the freeze via a proxy or type-cast,
// the returned object is a copy, not the internal state.
const returned = insets();
// Force an update via the legitimate API and confirm the returned
// value is not what the state now holds.
s.setState({ safeAreaInsets: { top: 10, right: 0, bottom: 0, left: 0 } });
// stale copy is unaffected
expect(returned.top).toBe(43);
// fresh read observes the update
expect(insets().top).toBe(10);
});

it('freezes array reads too', () => {
const s = newStateful();
const labels = s.getter('labels');

const first = labels();
expect(Object.isFrozen(first)).toBe(true);
expect(() => {
first.push('mutated');
}).toThrow(TypeError);
});

it('returns primitives unchanged', () => {
const s = newStateful();
const height = s.getter('height');

expect(height()).toBe(800);
});
});
});
32 changes: 31 additions & 1 deletion packages/sdk/src/composables/Stateful.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,15 @@ export class Stateful<S extends object> {

/**
* Creates a computed signal based on the state.
*
* If the underlying value is a plain object or array, a frozen shallow
* copy is returned so consumer mutations cannot leak back into the
* internal state. Primitives and other value types are returned as-is.
*
* @param key - a state key to use as a source.
*/
getter<K extends keyof S>(key: K): Computed<S[K]> {
return computed(() => this._state()[key]);
return computed(() => frozenClone(this._state()[key]));
}

/**
Expand All @@ -56,3 +61,28 @@ export class Stateful<S extends object> {
return !shallowEqual({ ...this.state(), ...removeUndefined(state) }, this.state());
}
}

/**
* Returns a frozen shallow copy of plain objects and arrays; primitives and
* other value types are returned unchanged. Guards `Stateful.getter` from
* handing out references into its internal state — the returned value is
* frozen so a consumer cannot mutate the object it reads back on the next
* call, and can't mutate the internal state either (spread produces a
* separate object, freeze prevents in-place mutation of the copy).
*
* Freeze is used instead of "copy on every read" because `computed(...)`
* memoises its function's output — a copy alone would be created once and
* then handed to every subsequent read.
*/
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);
// Only copy plain-object shapes; class instances (Maps, Sets, Dates, custom
// classes) round-trip by reference because a spread would strip prototype.
if (proto === null || proto === Object.prototype) {
return Object.freeze({ ...(value as object) }) as T;
}
}
return value;
}