@usetheo/ui 0.6.2-next.0 → 0.6.3-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,64 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.3-next.0] - 2026-05-23
11
+
12
+ Patch — eliminate React hydration mismatch in `<ThemeProvider>`. Reported
13
+ by the TheoKit framework team (2026-05-23): every SSR'd app using
14
+ `<ThemeSwitcher>` threw `Hydration failed because the server rendered
15
+ text didn't match the client` on every page reload after a user had
16
+ changed themes, then re-rendered the entire React tree client-side,
17
+ defeating SSR.
18
+
19
+ ### Fixed
20
+
21
+ - **SSR hydration mismatch on `<ThemeProvider>`** — three `useState`
22
+ calls (`themeName`, `mode`, `density`) previously ran their initializer
23
+ on BOTH server (no `window`, returned default) AND client at hydration
24
+ time (with `window`, returned `localStorage.getItem(…)`). The two
25
+ diverged → React threw + discarded the SSR'd tree on every page load.
26
+ Fixed by initializing with the SSR default ALWAYS, then promoting to
27
+ the stored value via a post-mount `useEffect` after hydration. The
28
+ visible-text nodes the React reconciler compares (switcher label,
29
+ `sr-only` announcement, `aria-label`) now match server → client.
30
+ Stored preferences still apply within one render tick of mount;
31
+ `<ThemeScript>` continues to set `data-theme` / `data-mode` /
32
+ `data-density` on `<html>` before React mounts to suppress the
33
+ 1-frame visual flicker. (#TBD)
34
+ - **Persist effect first-mount guard** — a `useRef`-based skip-first
35
+ flag prevents the persist effect from writing the SSR-safe defaults
36
+ to `localStorage` between mount and the post-mount hydration
37
+ setState. Previously, the brief window between commit and the
38
+ hydration effect could clobber the user's stored preference if the
39
+ page closed mid-render. After the first call, every subsequent
40
+ change (user-driven OR hydration-promoted) persists normally. (#TBD)
41
+
42
+ ### Added
43
+
44
+ - **`<ThemeScript defaultDensity>` prop** — the inline `<script>`
45
+ bootstrap now also sets `data-density` on `<html>` from
46
+ `localStorage.getItem(":density")` (or `defaultDensity`, default
47
+ `"comfortable"`) so density-driven layouts have zero FOUC at first
48
+ paint. Mirrors `ThemeProvider`'s `defaultDensity`. (#TBD)
49
+
50
+ ### Notes
51
+
52
+ - Pattern mirrors `next-themes` (Vercel), `MantineProvider`, and
53
+ shadcn/ui's theme scaffold. The 1-frame state-promotion delay is the
54
+ React-canonical price for SSR-safe client-only state. `<ThemeScript>`
55
+ pre-paints the `<html>` attributes so the visible layer doesn't
56
+ flicker.
57
+ - Two new unit tests guard the regression:
58
+ - `does NOT write to localStorage on first mount when nothing changes
59
+ (persist gate)` — verifies the skip-first guard.
60
+ - `writes to localStorage AFTER a user-driven change (persist fires
61
+ post-hydration)` — verifies the gate releases after the first call.
62
+ - Existing `reads initial theme name from localStorage` and `reads
63
+ initial mode from localStorage` tests continue to pass because
64
+ testing-library's `render()` flushes effects synchronously inside
65
+ `act()`, so by the assertion phase the post-mount hydration effect
66
+ has already promoted the stored value.
67
+
10
68
  ## [0.6.2-next.0] - 2026-05-23
11
69
 
12
70
  Patch — restore `cursor: pointer` on interactive buttons. Tailwind v4
package/dist/index.d.ts CHANGED
@@ -194,6 +194,14 @@ interface ThemeScriptProps {
194
194
  defaultTheme?: string;
195
195
  /** Mode to apply when no persisted value exists. Default `"dark"`. */
196
196
  defaultMode?: ThemeMode;
197
+ /**
198
+ * Density to apply when no persisted value exists. Default `"comfortable"`.
199
+ * Mirrors `ThemeProvider`'s `defaultDensity` so the inline-script and
200
+ * the React provider agree on the SSR-default density (and the
201
+ * `data-density` attribute set by this script matches what
202
+ * `ThemeProvider` promotes via its post-mount hydration effect).
203
+ */
204
+ defaultDensity?: "compact" | "comfortable" | "spacious";
197
205
  /**
198
206
  * localStorage namespace. Must match the `storageKey` passed to
199
207
  * `<ThemeProvider>`. Default `"theo-ui:theme"`. Pass `null` to disable
@@ -201,7 +209,7 @@ interface ThemeScriptProps {
201
209
  */
202
210
  storageKey?: string | null;
203
211
  }
204
- declare function ThemeScript({ defaultTheme, defaultMode, storageKey, }: ThemeScriptProps): JSX$1.Element;
212
+ declare function ThemeScript({ defaultTheme, defaultMode, defaultDensity, storageKey, }: ThemeScriptProps): JSX$1.Element;
205
213
 
206
214
  interface ThemeSwitcherProps {
207
215
  className?: string;
package/dist/index.js CHANGED
@@ -188,35 +188,25 @@ function ThemeProvider({
188
188
  useEffect(() => {
189
189
  setThemes(mergedThemes);
190
190
  }, [mergedThemes]);
191
- const [themeName, setThemeName] = useState(() => {
192
- if (typeof window === "undefined" || !storageKey) return defaultTheme;
193
- try {
194
- return window.localStorage.getItem(`${storageKey}:name`) ?? defaultTheme;
195
- } catch (err) {
196
- warnStorageFailure("read theme name", err);
197
- return defaultTheme;
198
- }
199
- });
200
- const [mode, setModeState] = useState(() => {
201
- if (typeof window === "undefined" || !storageKey) return defaultMode;
202
- try {
203
- const stored = window.localStorage.getItem(`${storageKey}:mode`);
204
- return stored === "dark" || stored === "light" ? stored : defaultMode;
205
- } catch (err) {
206
- warnStorageFailure("read theme mode", err);
207
- return defaultMode;
208
- }
209
- });
210
- const [density, setDensityState] = useState(() => {
211
- if (typeof window === "undefined" || !storageKey) return defaultDensity;
191
+ const [themeName, setThemeName] = useState(defaultTheme);
192
+ const [mode, setModeState] = useState(defaultMode);
193
+ const [density, setDensityState] = useState(defaultDensity);
194
+ const skipFirstPersistRef = useRef(true);
195
+ useEffect(() => {
196
+ if (typeof window === "undefined" || !storageKey) return;
212
197
  try {
213
- const stored = window.localStorage.getItem(`${storageKey}:density`);
214
- return stored === "compact" || stored === "comfortable" || stored === "spacious" ? stored : defaultDensity;
198
+ const storedName = window.localStorage.getItem(`${storageKey}:name`);
199
+ const storedMode = window.localStorage.getItem(`${storageKey}:mode`);
200
+ const storedDensity = window.localStorage.getItem(`${storageKey}:density`);
201
+ if (storedName) setThemeName(storedName);
202
+ if (storedMode === "dark" || storedMode === "light") setModeState(storedMode);
203
+ if (storedDensity === "compact" || storedDensity === "comfortable" || storedDensity === "spacious") {
204
+ setDensityState(storedDensity);
205
+ }
215
206
  } catch (err) {
216
- warnStorageFailure("read density", err);
217
- return defaultDensity;
207
+ warnStorageFailure("read theme + mode + density", err);
218
208
  }
219
- });
209
+ }, [storageKey]);
220
210
  useEffect(() => {
221
211
  injectThemeCss(themes);
222
212
  }, [themes]);
@@ -232,6 +222,10 @@ function ThemeProvider({
232
222
  injectDensityCss();
233
223
  }, [themeName, mode, density, themes]);
234
224
  useEffect(() => {
225
+ if (skipFirstPersistRef.current) {
226
+ skipFirstPersistRef.current = false;
227
+ return;
228
+ }
235
229
  if (typeof window === "undefined" || !storageKey) return;
236
230
  try {
237
231
  window.localStorage.setItem(`${storageKey}:name`, themeName);
@@ -285,18 +279,20 @@ function useTheme() {
285
279
  function safe(value) {
286
280
  return JSON.stringify(value).replace(/</g, "\\u003c");
287
281
  }
288
- function buildScript(defaultTheme, defaultMode, storageKey) {
282
+ function buildScript(defaultTheme, defaultMode, defaultDensity, storageKey) {
289
283
  const k = safe(storageKey);
290
284
  const t = safe(defaultTheme);
291
285
  const m = safe(defaultMode);
292
- return `(function(){try{var k=${k};var d=document.documentElement;var t=null;var m=null;if(k){t=localStorage.getItem(k+":name");m=localStorage.getItem(k+":mode");}d.setAttribute("data-theme",t||${t});d.setAttribute("data-mode",m||${m});if((m||${m})==="dark"){d.classList.add("dark");}}catch(e){}})();`;
286
+ const dn = safe(defaultDensity);
287
+ return `(function(){try{var k=${k};var d=document.documentElement;var t=null;var m=null;var dn=null;if(k){t=localStorage.getItem(k+":name");m=localStorage.getItem(k+":mode");dn=localStorage.getItem(k+":density");}d.setAttribute("data-theme",t||${t});d.setAttribute("data-mode",m||${m});d.setAttribute("data-density",dn||${dn});if((m||${m})==="dark"){d.classList.add("dark");}}catch(e){}})();`;
293
288
  }
294
289
  function ThemeScript({
295
290
  defaultTheme = "violet-forge",
296
291
  defaultMode = "dark",
292
+ defaultDensity = "comfortable",
297
293
  storageKey = "theo-ui:theme"
298
294
  }) {
299
- const code = buildScript(defaultTheme, defaultMode, storageKey);
295
+ const code = buildScript(defaultTheme, defaultMode, defaultDensity, storageKey);
300
296
  return /* @__PURE__ */ jsx("script", { suppressHydrationWarning: true, dangerouslySetInnerHTML: { __html: code } });
301
297
  }
302
298
  function ThemeSwitcher({ className, showModeToggle = true }) {