diff --git a/.changeset/fix-stateful-getter-ref-leak.md b/.changeset/fix-stateful-getter-ref-leak.md new file mode 100644 index 000000000..f875a4c77 --- /dev/null +++ b/.changeset/fix-stateful-getter-ref-leak.md @@ -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. diff --git a/packages/sdk/src/composables/Stateful.test.ts b/packages/sdk/src/composables/Stateful.test.ts new file mode 100644 index 000000000..984484e61 --- /dev/null +++ b/packages/sdk/src/composables/Stateful.test.ts @@ -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({ + 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); + }); + }); +}); diff --git a/packages/sdk/src/composables/Stateful.ts b/packages/sdk/src/composables/Stateful.ts index 279479ffb..6fb99eba9 100644 --- a/packages/sdk/src/composables/Stateful.ts +++ b/packages/sdk/src/composables/Stateful.ts @@ -31,10 +31,15 @@ export class Stateful { /** * 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(key: K): Computed { - return computed(() => this._state()[key]); + return computed(() => frozenClone(this._state()[key])); } /** @@ -56,3 +61,28 @@ export class Stateful { 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(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; +}