Skip to content

Commit f770044

Browse files
authored
fix: make hash persistence unicode-safe (#367)
1 parent 30a1639 commit f770044

2 files changed

Lines changed: 229 additions & 2 deletions

File tree

e2e-tests/persistence.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { expect, test, type Page } from "@playwright/test";
2+
3+
const storageKey = "eslint-explorer";
4+
5+
async function getPersistedJavaScriptCode(page: Page): Promise<string> {
6+
return page.evaluate(key => {
7+
const storedValue = window.localStorage.getItem(key);
8+
9+
if (!storedValue) {
10+
return "";
11+
}
12+
13+
return JSON.parse(storedValue).state.code.javascript;
14+
}, storageKey);
15+
}
16+
17+
async function getPersistedExplorerState(page: Page): Promise<string> {
18+
const persistedValue = await page.evaluate(
19+
key => window.localStorage.getItem(key),
20+
storageKey,
21+
);
22+
23+
if (!persistedValue) {
24+
throw new Error("Expected explorer state to be persisted");
25+
}
26+
27+
return persistedValue;
28+
}
29+
30+
async function getStoredHashValue(page: Page): Promise<string> {
31+
return page.evaluate(key => {
32+
return (
33+
new URLSearchParams(window.location.hash.slice(1)).get(key) ?? ""
34+
);
35+
}, storageKey);
36+
}
37+
38+
async function replaceEditorValue(page: Page, value: string) {
39+
const codeEditor = page
40+
.getByRole("region", { name: "Code Editor Panel" })
41+
.getByRole("textbox")
42+
.nth(1);
43+
44+
await codeEditor.click();
45+
await codeEditor.press("ControlOrMeta+KeyA");
46+
await codeEditor.press("Backspace");
47+
await codeEditor.pressSequentially(value);
48+
}
49+
50+
test("should persist unicode code safely in the URL hash", async ({ page }) => {
51+
await page.addInitScript(key => {
52+
window.localStorage.removeItem(key);
53+
}, storageKey);
54+
await page.goto("/");
55+
56+
const unicodeCode = 'const \u03C0 = "\u{1F600}";';
57+
58+
await replaceEditorValue(page, unicodeCode);
59+
60+
await expect.poll(() => getPersistedJavaScriptCode(page)).toBe(unicodeCode);
61+
await expect.poll(() => getStoredHashValue(page)).toContain("v2.");
62+
63+
const persistedHash = await page.evaluate(() => window.location.hash);
64+
65+
await page.evaluate(key => {
66+
window.localStorage.removeItem(key);
67+
}, storageKey);
68+
await page.goto(`/${persistedHash}`);
69+
70+
await expect(page.locator(".cm-content")).toContainText(unicodeCode);
71+
});
72+
73+
test("should still load state from legacy hash links", async ({ page }) => {
74+
await page.addInitScript(key => {
75+
window.localStorage.removeItem(key);
76+
}, storageKey);
77+
await page.goto("/");
78+
79+
const legacyCode = "console.log('legacy hash');";
80+
81+
await replaceEditorValue(page, legacyCode);
82+
83+
await expect.poll(() => getPersistedJavaScriptCode(page)).toBe(legacyCode);
84+
85+
const persistedValue = await getPersistedExplorerState(page);
86+
87+
const legacyHash = await page.evaluate(
88+
([key, value]) => {
89+
const searchParams = new URLSearchParams();
90+
searchParams.set(key, btoa(JSON.stringify(value)));
91+
return searchParams.toString();
92+
},
93+
[storageKey, persistedValue],
94+
);
95+
96+
await page.evaluate(key => {
97+
window.localStorage.removeItem(key);
98+
}, storageKey);
99+
await page.goto(`/#${legacyHash}`);
100+
101+
await expect(page.locator(".cm-content")).toContainText(legacyCode);
102+
});
103+
104+
test("should fall back to localStorage when a v2 hash is malformed", async ({
105+
page,
106+
}) => {
107+
await page.addInitScript(key => {
108+
window.localStorage.removeItem(key);
109+
}, storageKey);
110+
await page.goto("/");
111+
112+
const fallbackCode = "console.log('localStorage fallback');";
113+
114+
await replaceEditorValue(page, fallbackCode);
115+
116+
await expect
117+
.poll(() => getPersistedJavaScriptCode(page))
118+
.toBe(fallbackCode);
119+
120+
const persistedValue = await getPersistedExplorerState(page);
121+
122+
const malformedHash = await page.evaluate(key => {
123+
const bytes = new TextEncoder().encode("not valid persisted state");
124+
let binary = "";
125+
126+
for (const byte of bytes) {
127+
binary += String.fromCharCode(byte);
128+
}
129+
130+
const base64Url = btoa(binary)
131+
.replace(/\+/g, "-")
132+
.replace(/\//g, "_")
133+
.replace(/=+$/, "");
134+
135+
const searchParams = new URLSearchParams();
136+
searchParams.set(key, `v2.${base64Url}`);
137+
return searchParams.toString();
138+
}, storageKey);
139+
140+
await page.evaluate(
141+
([key, value]) => {
142+
window.localStorage.setItem(key, value);
143+
},
144+
[storageKey, persistedValue],
145+
);
146+
await page.goto(`/#${malformedHash}`);
147+
148+
await expect(page.locator(".cm-content")).toContainText(fallbackCode);
149+
});

src/hooks/use-explorer.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,20 +117,98 @@ const getHashParams = (): URLSearchParams => {
117117
return new URLSearchParams(location.hash.slice(1));
118118
};
119119

120+
const versionedHashPrefix = "v2.";
121+
122+
function isPersistedStorageValue(
123+
value: unknown,
124+
): value is { state: unknown; version: number } {
125+
return (
126+
typeof value === "object" &&
127+
value !== null &&
128+
"state" in value &&
129+
"version" in value &&
130+
typeof value.version === "number"
131+
);
132+
}
133+
134+
function isSerializedPersistedStorageValue(value: string) {
135+
try {
136+
const parsedValue = JSON.parse(value);
137+
return isPersistedStorageValue(parsedValue);
138+
} catch {
139+
return false;
140+
}
141+
}
142+
143+
function encodeBase64Url(value: string) {
144+
return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
145+
}
146+
147+
function decodeBase64Url(value: string) {
148+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
149+
const paddingLength = (4 - (base64.length % 4)) % 4;
150+
151+
return `${base64}${"=".repeat(paddingLength)}`;
152+
}
153+
154+
function decodeVersionedHashStorageValue(value: string) {
155+
const bytes = Uint8Array.from(value, character => character.charCodeAt(0));
156+
const decodedValue = new TextDecoder().decode(bytes);
157+
158+
return isSerializedPersistedStorageValue(decodedValue)
159+
? decodedValue
160+
: null;
161+
}
162+
163+
function encodeHashStorageValue(value: string) {
164+
const bytes = new TextEncoder().encode(value);
165+
let binary = "";
166+
167+
for (const byte of bytes) {
168+
binary += String.fromCharCode(byte);
169+
}
170+
171+
return `${versionedHashPrefix}${encodeBase64Url(btoa(binary))}`;
172+
}
173+
174+
function decodeHashStorageValue(value: string) {
175+
try {
176+
if (value.startsWith(versionedHashPrefix)) {
177+
const binary = atob(
178+
decodeBase64Url(value.slice(versionedHashPrefix.length)),
179+
);
180+
181+
return decodeVersionedHashStorageValue(binary);
182+
}
183+
184+
const legacyValue = JSON.parse(atob(value));
185+
return typeof legacyValue === "string" &&
186+
isSerializedPersistedStorageValue(legacyValue)
187+
? legacyValue
188+
: null;
189+
} catch {
190+
return null;
191+
}
192+
}
193+
120194
const hybridStorage: StateStorage = {
121195
getItem: (key): string => {
122196
// Priority: URL hash first, then localStorage fallback
123197
const hashValue = getHashParams().get(key);
124198
if (hashValue) {
125-
return JSON.parse(atob(hashValue));
199+
const decodedHashValue = decodeHashStorageValue(hashValue);
200+
201+
if (decodedHashValue !== null) {
202+
return decodedHashValue;
203+
}
126204
}
127205

128206
const localValue = localStorage.getItem(key);
129207
return localValue || "";
130208
},
131209
setItem: (key, newValue): void => {
132210
const searchParams = getHashParams();
133-
const encodedValue = btoa(JSON.stringify(newValue));
211+
const encodedValue = encodeHashStorageValue(newValue);
134212
searchParams.set(key, encodedValue);
135213
location.hash = searchParams.toString();
136214

0 commit comments

Comments
 (0)