diff --git a/.gitignore b/.gitignore index bf945d3..a252c68 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# internal +ROADMAP.md diff --git a/src/index.test.ts b/src/index.test.ts index 7c4a852..6857863 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -99,4 +99,27 @@ describe("toTypes", () => { expect(toTypes()).toContain("light"); expect(toTypes()).toContain("dark"); }); + + it("returns empty string when themes is empty", () => { + const { toTypes: toTypesEmpty } = defineThemes({ + prefix: "ui", + tokens: ["accent"] as const, + themes: {}, + }); + expect(toTypesEmpty()).toBe(""); + }); +}); + +describe("inject — edge cases", () => { + it("does nothing when document.head is null", () => { + const { inject } = defineThemes({ + prefix: "edge", + tokens: ["accent"] as const, + themes: { light: ["#fff"] }, + }); + const original = document.head; + Object.defineProperty(document, "head", { value: null, configurable: true }); + expect(() => inject()).not.toThrow(); + Object.defineProperty(document, "head", { value: original, configurable: true }); + }); }); diff --git a/src/index.ts b/src/index.ts index 86a48e8..cbf63ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,7 @@ export const defineThemes = ({ }: DefineThemesConfig) => { const map: Record> = {}; - for (const t in themes) { + for (const t of Object.keys(themes)) { const vars: Record = {}; tokens.forEach((token, i) => { vars[key(token, prefix)] = themes[t]?.[i] ?? ""; @@ -23,7 +23,7 @@ export const defineThemes = ({ const toCSS = () => { let r = "", first = true; - for (const t in map) { + for (const t of Object.keys(map)) { if (first) { r += `:root,\n`; first = false; @@ -38,7 +38,7 @@ export const defineThemes = ({ }; const inject = () => { - if (typeof document === "undefined") return; + if (typeof document === "undefined" || !document.head) return; const id = `${VARTH}-${prefix}`; const el = document.getElementById(id) ?? @@ -51,11 +51,13 @@ export const defineThemes = ({ const themeNames = Object.keys(map); const toTypes = () => { + if (themeNames.length === 0) return ""; + const firstTheme = themeNames[0] as string; const union = (names: string[]) => names.map((n) => ` | '${n}'`).join("\n"); return [ `export type ${THEME_TOKEN} =`, - union(Object.keys(getVarths(themeNames[0] ?? ""))), + union(Object.keys(getVarths(firstTheme))), ``, `export type ${THEME_NAME} =`, union(themeNames), diff --git a/src/react/index.test.tsx b/src/react/index.test.tsx index 6f03908..0e4b60b 100644 --- a/src/react/index.test.tsx +++ b/src/react/index.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; afterEach(() => { cleanup(); + localStorage.clear(); }); const { inject, themeNames } = defineThemes({ @@ -70,4 +71,34 @@ describe("useTheme", () => { fireEvent.click(screen.getByTestId("button-click")); expect(screen.getByText("current: dark")).not.toBeNull(); }); + + it("setTheme ignores invalid theme names", () => { + const InvalidSwitcher = () => { + const { theme, setTheme } = useTheme(); + return ( + + ); + }; + render( + + + , + ); + fireEvent.click(screen.getByTestId("invalid")); + expect(screen.getByText("current: light")).not.toBeNull(); + }); + + it("setTheme does not throw when localStorage.setItem throws", () => { + const original = localStorage.setItem.bind(localStorage); + localStorage.setItem = () => { throw new Error("QuotaExceededError"); }; + render( + + + , + ); + expect(() => fireEvent.click(screen.getByTestId("button-click"))).not.toThrow(); + localStorage.setItem = original; + }); }); diff --git a/src/react/index.tsx b/src/react/index.tsx index d3c3bd2..a297384 100644 --- a/src/react/index.tsx +++ b/src/react/index.tsx @@ -35,13 +35,20 @@ export const ThemeProvider = ({ ); const setTheme = (next: string) => { + if (!themeNames.includes(next)) return; setThemeState(next); - localStorage.setItem("varth-theme", next); + if (typeof localStorage !== "undefined") { + try { + localStorage.setItem("varth-theme", next); + } catch { + // ignore QuotaExceededError / SecurityError + } + } }; useEffect(() => { inject(); - }, []); + }, [inject]); return (