From 599240033a8a9a9b64bf73b11abcc9a02c8019f6 Mon Sep 17 00:00:00 2001 From: Bart Louwers Date: Fri, 4 Jul 2025 10:27:00 +0200 Subject: [PATCH] Handle QuotaExceededError in StyleStore (#1253) ## Launch Checklist When localStorage is full you start getting a "QuotaExceededError". RIght now Maputnik does not handle this situation gracefully, it just fails loading the style with a non-descriptive error message. This PR purges localStorage and tries again when this particular error happens. It still does not show a descriptive error message. If you try to load a style that is larger than what localStorage can handle, it will still fail with a non-descriptive error message. Increased the size of `example-style.json` so that it causes a QuotaExceededError when running the regression test (try it before and after this PR). - [x] Briefly describe the changes in this PR. - [x] Link to related issues. N/A - [x] Include before/after visuals or gifs if this PR includes visual changes. N/A - [x] Write tests for all new functionality. - [x] Add an entry to `CHANGELOG.md` under the `## main` section. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + cypress/e2e/maputnik-driver.ts | 6 +- cypress/e2e/modals.cy.ts | 40 +++++++++ cypress/fixtures/example-style.json | 122 +++++++++++++++++++++++++--- src/libs/stylestore.ts | 24 +++++- 5 files changed, 179 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f6bf9e..c6f88950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Upgrade to MapLibre LG JS v5 - Upgrade Vite 6 and Cypress 14 ([#970](https://github.com/maplibre/maputnik/pull/970)) - Upgrade OpenLayers from v6 to v10 +- When loading a style into localStorage that causes a QuotaExceededError, purge localStorage and retry - _...Add new stuff here..._ ### 🐞 Bug fixes diff --git a/cypress/e2e/maputnik-driver.ts b/cypress/e2e/maputnik-driver.ts index ddb8b899..436fa55c 100644 --- a/cypress/e2e/maputnik-driver.ts +++ b/cypress/e2e/maputnik-driver.ts @@ -8,8 +8,10 @@ const baseUrl = "http://localhost:8888/"; const styleFromWindow = (win: Window) => { const styleId = win.localStorage.getItem("maputnik:latest_style"); - const styleItem = win.localStorage.getItem(`maputnik:style:${styleId}`); - const obj = JSON.parse(styleItem || ""); + const styleItemKey = `maputnik:style:${styleId}`; + const styleItem = win.localStorage.getItem(styleItemKey); + if (!styleItem) throw new Error("Could not get styleItem from localStorage"); + const obj = JSON.parse(styleItem); return obj; }; diff --git a/cypress/e2e/modals.cy.ts b/cypress/e2e/modals.cy.ts index 89d63359..71420b78 100644 --- a/cypress/e2e/modals.cy.ts +++ b/cypress/e2e/modals.cy.ts @@ -275,4 +275,44 @@ describe("modals", () => { describe("sources", () => { it("toggle"); }); + + describe("Handle localStorage QuotaExceededError", () => { + it("handles quota exceeded error when opening style from URL", () => { + // Clear localStorage to start fresh + cy.clearLocalStorage(); + + // fill localStorage until we get a QuotaExceededError + cy.window().then(win => { + let chunkSize = 1000; + const chunk = new Array(chunkSize).join("x"); + let index = 0; + + // Keep adding until we hit the quota + while (true) { + try { + const key = `maputnik:fill-${index++}`; + win.localStorage.setItem(key, chunk); + } catch (e: any) { + // Verify it's a quota error + if (e.name === 'QuotaExceededError') { + if (chunkSize <= 1) return; + else { + chunkSize /= 2; + continue; + } + } + throw e; // Unexpected error + } + } + }); + + // Open the style via URL input + when.click("nav:open"); + when.setValue("modal:open.url.input", get.exampleFileUrl()); + when.click("modal:open.url.button"); + + then(get.responseBody("example-style.json")).shouldEqualToStoredStyle(); + then(get.styleFromLocalStorage()).shouldExist(); + }); + }); }); diff --git a/cypress/fixtures/example-style.json b/cypress/fixtures/example-style.json index 150ccf84..2558b112 100644 --- a/cypress/fixtures/example-style.json +++ b/cypress/fixtures/example-style.json @@ -1,12 +1,114 @@ { - "id": "test-style", - "version": 8, - "name": "Test Style", - "metadata": { - "maputnik:renderer": "mlgljs" - }, - "sources": {}, - "glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf", - "sprites": "https://example.local/fonts/{fontstack}/{range}.pbf", - "layers": [] + "id": "test-style", + "version": 8, + "name": "Test Style", + "metadata": { + "maputnik:renderer": "mlgljs", + "data": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99 + ] + }, + "sources": {}, + "glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf", + "sprites": "https://example.local/fonts/{fontstack}/{range}.pbf", + "layers": [] } diff --git a/src/libs/stylestore.ts b/src/libs/stylestore.ts index b74f7907..a97a74cf 100644 --- a/src/libs/stylestore.ts +++ b/src/libs/stylestore.ts @@ -91,8 +91,28 @@ export class StyleStore { save(mapStyle: StyleSpecification & { id: string }) { mapStyle = style.ensureStyleValidity(mapStyle) const key = styleKey(mapStyle.id) - window.localStorage.setItem(key, JSON.stringify(mapStyle)) - window.localStorage.setItem(storageKeys.latest, mapStyle.id) + + const saveFn = () => { + window.localStorage.setItem(key, JSON.stringify(mapStyle)) + window.localStorage.setItem(storageKeys.latest, mapStyle.id) + } + + try { + saveFn() + } catch (e) { + // Handle quota exceeded error + if (e instanceof DOMException && ( + e.code === 22 || // Firefox + e.code === 1014 || // Firefox + e.name === 'QuotaExceededError' || + e.name === 'NS_ERROR_DOM_QUOTA_REACHED' + )) { + this.purge() + saveFn() // Retry after clearing + } else { + throw e + } + } return mapStyle } }