diff --git a/CHANGELOG.md b/CHANGELOG.md index 90fa1b1..2b1ce84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Fixed + +- Fixed saving from the Figma plugin, which did nothing and logged `SecurityError: Failed to read the 'localStorage' property from 'Window'`. The editor runs inside Figma's sandboxed `about:srcdoc` frame, where reading `localStorage` is denied, and the save's rate limiter read it on every push and threw before the save could run. Storage access now falls back to an in-memory store when the browser blocks it, so saving from the Figma Desktop app works again. + ## [2.0.1] - 2026-08-18 ### Fixed diff --git a/packages/www/src/App.tsx b/packages/www/src/App.tsx index d9dd0a1..de6d53b 100644 --- a/packages/www/src/App.tsx +++ b/packages/www/src/App.tsx @@ -4,6 +4,7 @@ import clsx from "clsx"; import { devLog } from "functions/devLog"; import { isEmbed } from "functions/isEmbed"; import { preparePresetToLoad } from "functions/preparePresetToLoad"; +import { safeLocalStorage } from "functions/safeLocalStorage"; import { sanitizePreset } from "functions/sanitizePreset"; import { validatePreset } from "functions/validatePreset"; import { useAtom, useAtomValue, useSetAtom } from "jotai"; @@ -262,7 +263,7 @@ export function App() { } function handleLocal() { - const current_framework = localStorage.getItem("current_framework"); + const current_framework = safeLocalStorage.getItem("current_framework"); if (current_framework) { const parsed = JSON.parse(current_framework); diff --git a/packages/www/src/__tests__/figmaSandboxStorage.test.ts b/packages/www/src/__tests__/figmaSandboxStorage.test.ts new file mode 100644 index 0000000..4197253 --- /dev/null +++ b/packages/www/src/__tests__/figmaSandboxStorage.test.ts @@ -0,0 +1,99 @@ +import { safeLocalStorage } from "functions/safeLocalStorage"; +import { rateLimiter } from "hooks/usePush"; + +// Reproduces issue #11: inside the Figma plugin the editor runs in an +// `about:srcdoc` iframe where reading `window.localStorage` throws +// SecurityError: Failed to read the 'localStorage' property from 'Window': +// Access is denied for this document. +// The save path (usePush's rateLimiter) touched localStorage unconditionally, +// so the click threw an uncaught promise rejection and the save never ran. + +const LIMITER_KEY = "cf-limiter"; +const originalDescriptor = Object.getOwnPropertyDescriptor(window, "localStorage"); + +function denyLocalStorage() { + Object.defineProperty(window, "localStorage", { + configurable: true, + get() { + throw new DOMException( + "Failed to read the 'localStorage' property from 'Window': Access is denied for this document.", + "SecurityError", + ); + }, + }); +} + +function allowLocalStorage() { + if (originalDescriptor) { + Object.defineProperty(window, "localStorage", originalDescriptor); + } +} + +// `safeLocalStorage` keeps a module-level in-memory fallback that persists +// across tests. Reset both backing stores before each test so the limiter +// timestamp from one test does not throttle the next. +beforeEach(() => { + allowLocalStorage(); + window.localStorage.clear(); + denyLocalStorage(); + safeLocalStorage.removeItem(LIMITER_KEY); + allowLocalStorage(); +}); + +afterEach(() => { + allowLocalStorage(); + window.localStorage.clear(); +}); + +describe("save path under a denied-storage sandbox (issue #11)", () => { + test("save proceeds when storage access is denied (Figma srcdoc)", async () => { + denyLocalStorage(); + + const save = jest.fn().mockResolvedValue(undefined); + + // Must not reject with the SecurityError, and must still run the save. + await expect(rateLimiter(save)()).resolves.toBeUndefined(); + expect(save).toHaveBeenCalledTimes(1); + }); + + test("rate limiting still throttles with the in-memory fallback", async () => { + denyLocalStorage(); + + const save = jest.fn().mockResolvedValue(undefined); + const limitedSave = rateLimiter(save); + + await limitedSave(); + await limitedSave(); // second call within the 2s window is throttled + + expect(save).toHaveBeenCalledTimes(1); + }); + + test("normal storage path is unchanged (timestamp persisted)", async () => { + const save = jest.fn().mockResolvedValue(undefined); + await rateLimiter(save)(); + + expect(window.localStorage.getItem(LIMITER_KEY)).not.toBeNull(); + expect(save).toHaveBeenCalledTimes(1); + }); +}); + +describe("safeLocalStorage", () => { + test("falls back to an in-memory store when access is denied", () => { + denyLocalStorage(); + + expect(() => safeLocalStorage.setItem("probe", "v")).not.toThrow(); + expect(safeLocalStorage.getItem("probe")).toBe("v"); + + safeLocalStorage.removeItem("probe"); + expect(safeLocalStorage.getItem("probe")).toBeNull(); + }); + + test("reads and writes real localStorage when it is available", () => { + safeLocalStorage.setItem("probe", "v"); + + expect(window.localStorage.getItem("probe")).toBe("v"); + expect(safeLocalStorage.getItem("probe")).toBe("v"); + + safeLocalStorage.removeItem("probe"); + }); +}); diff --git a/packages/www/src/functions/safeLocalStorage.ts b/packages/www/src/functions/safeLocalStorage.ts new file mode 100644 index 0000000..ce0108c --- /dev/null +++ b/packages/www/src/functions/safeLocalStorage.ts @@ -0,0 +1,41 @@ +// A `localStorage` wrapper that never throws. +// +// The editor is bundled into the Figma plugin, where it runs inside an +// `about:srcdoc` iframe. Reading `window.localStorage` there throws +// `SecurityError: Failed to read the 'localStorage' property from 'Window': +// Access is denied for this document.` (issue #11) — which killed the save +// click as an uncaught promise rejection. The same access is denied in some +// privacy modes and cross-origin sandboxes. +// +// When the real store is unreachable we transparently fall back to an +// in-memory map so storage-backed features keep working for the session +// instead of crashing. On the web, `window.localStorage` is available and +// behaviour is unchanged. + +const memoryStore = new Map(); + +export const safeLocalStorage = { + getItem(key: string): string | null { + try { + return window.localStorage.getItem(key); + } catch { + return memoryStore.has(key) ? (memoryStore.get(key) as string) : null; + } + }, + + setItem(key: string, value: string): void { + try { + window.localStorage.setItem(key, value); + } catch { + memoryStore.set(key, value); + } + }, + + removeItem(key: string): void { + try { + window.localStorage.removeItem(key); + } catch { + memoryStore.delete(key); + } + }, +}; diff --git a/packages/www/src/hooks/usePush.ts b/packages/www/src/hooks/usePush.ts index 974bc44..0942c2b 100644 --- a/packages/www/src/hooks/usePush.ts +++ b/packages/www/src/hooks/usePush.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { isEmbed } from "functions/isEmbed"; import { isFigma } from "functions/isFigma"; import { minifyCss } from "functions/minifyCss"; +import { safeLocalStorage } from "functions/safeLocalStorage"; import { sanitizePreset } from "functions/sanitizePreset"; import { useSetAtom } from "jotai"; import { useAtomCallback } from "jotai/utils"; @@ -25,11 +26,11 @@ import { usePushFigma } from "./usePushFigma"; const LIMITER_LOCAL_STORAGE_KEY = "cf-limiter"; const LIMITER_TIMEOUT = 2000; -const rateLimiter = +export const rateLimiter = Promise>(fn: T) => async (...args: Parameters) => { const now = Date.now(); - const lastCall = localStorage.getItem(LIMITER_LOCAL_STORAGE_KEY); + const lastCall = safeLocalStorage.getItem(LIMITER_LOCAL_STORAGE_KEY); if (lastCall) { const diff = now - Number(lastCall); @@ -40,7 +41,7 @@ const rateLimiter = } } - localStorage.setItem(LIMITER_LOCAL_STORAGE_KEY, String(now)); + safeLocalStorage.setItem(LIMITER_LOCAL_STORAGE_KEY, String(now)); // Add small delay to ensure all state updates are flushed await new Promise((resolve) => setTimeout(resolve, 150)); @@ -243,7 +244,7 @@ export function usePush() { "*", ); } else { - localStorage.setItem("current_framework", JSON.stringify(sanitizePreset(newPresetData))); + safeLocalStorage.setItem("current_framework", JSON.stringify(sanitizePreset(newPresetData))); } // Store the current state as the last saved state with sorted keys for consistent comparison