@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 +58 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +25 -29
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
214
|
-
|
|
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
|
-
|
|
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 }) {
|