|
1 | 1 | import type { DesktopBridge } from "@t3tools/contracts"; |
2 | 2 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; |
3 | 3 | import * as Schema from "effect/Schema"; |
4 | | -import { useCallback, useEffect, useSyncExternalStore } from "react"; |
| 4 | +import { useEffect, useSyncExternalStore } from "react"; |
5 | 5 | import { |
6 | 6 | applyThemePalette, |
7 | 7 | CUSTOM_THEMES_STORAGE_KEY, |
@@ -492,157 +492,153 @@ function subscribe(listener: () => void): () => void { |
492 | 492 | }; |
493 | 493 | } |
494 | 494 |
|
495 | | -export function useTheme() { |
496 | | - const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); |
497 | | - const { theme, resolvedTheme } = snapshot; |
498 | | - |
499 | | - const setTheme = useCallback((next: Theme): boolean => { |
500 | | - if (typeof window === "undefined") return false; |
501 | | - try { |
502 | | - // Preserve the current mode before replacing a legacy or inferred theme |
503 | | - // preference. Otherwise a fresh System preference is re-inferred from |
504 | | - // the new theme's base appearance, which can switch a dark UI to light. |
505 | | - writeAppearanceModePreference(readAppearanceModePreference(getStored())); |
506 | | - // Choosing a whole theme replaces any automatic-mode mix. The mix is |
507 | | - // captured first so a failed preference write can put it back instead |
508 | | - // of erasing it or leaving it attached to the new theme. |
509 | | - const previousHalvesRaw = window.localStorage.getItem(THEME_HALVES_STORAGE_KEY); |
510 | | - window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); |
511 | | - try { |
512 | | - writeThemePreference(next); |
513 | | - } catch (cause) { |
514 | | - if (previousHalvesRaw !== null) { |
515 | | - try { |
516 | | - window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, previousHalvesRaw); |
517 | | - } catch {} |
518 | | - } |
519 | | - throw cause; |
520 | | - } |
521 | | - } catch (cause) { |
522 | | - const error = isThemeStorageError(cause) |
523 | | - ? cause |
524 | | - : new ThemeStorageError({ |
525 | | - operation: "write", |
526 | | - storageKey: STORAGE_KEY, |
527 | | - theme: next, |
528 | | - cause, |
529 | | - }); |
530 | | - console.error(error.message, { |
531 | | - operation: error.operation, |
532 | | - storageKey: error.storageKey, |
533 | | - theme: next, |
534 | | - ...safeErrorLogAttributes(error), |
535 | | - }); |
536 | | - return false; |
537 | | - } |
538 | | - applyTheme(next, { suppressTransitions: true }); |
539 | | - emitChange(); |
540 | | - return true; |
541 | | - }, []); |
542 | | - |
543 | | - const setAppearanceMode = useCallback((nextAppearanceMode: ThemePreferenceMode): boolean => { |
544 | | - if (typeof window === "undefined") return false; |
| 495 | +function setTheme(next: Theme): boolean { |
| 496 | + if (typeof window === "undefined") return false; |
| 497 | + try { |
| 498 | + // Preserve the current mode before replacing a legacy or inferred theme |
| 499 | + // preference. Otherwise a fresh System preference is re-inferred from |
| 500 | + // the new theme's base appearance, which can switch a dark UI to light. |
| 501 | + writeAppearanceModePreference(readAppearanceModePreference(getStored())); |
| 502 | + // Choosing a whole theme replaces any automatic-mode mix. The mix is |
| 503 | + // captured first so a failed preference write can put it back instead |
| 504 | + // of erasing it or leaving it attached to the new theme. |
| 505 | + const previousHalvesRaw = window.localStorage.getItem(THEME_HALVES_STORAGE_KEY); |
| 506 | + window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); |
545 | 507 | try { |
546 | | - writeAppearanceModePreference(nextAppearanceMode); |
| 508 | + writeThemePreference(next); |
547 | 509 | } catch (cause) { |
548 | | - const error = isThemeStorageError(cause) |
549 | | - ? cause |
550 | | - : new ThemeStorageError({ |
551 | | - operation: "write", |
552 | | - storageKey: THEME_APPEARANCE_MODE_STORAGE_KEY, |
553 | | - cause, |
554 | | - }); |
555 | | - console.error(error.message, { |
556 | | - operation: error.operation, |
557 | | - storageKey: error.storageKey, |
558 | | - ...safeErrorLogAttributes(error), |
559 | | - }); |
560 | | - return false; |
| 510 | + if (previousHalvesRaw !== null) { |
| 511 | + try { |
| 512 | + window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, previousHalvesRaw); |
| 513 | + } catch {} |
| 514 | + } |
| 515 | + throw cause; |
561 | 516 | } |
562 | | - themeStorageReadFailure = null; |
563 | | - applyTheme(getStored(), { suppressTransitions: true }); |
564 | | - emitChange(); |
565 | | - return true; |
566 | | - }, []); |
567 | | - |
568 | | - const setFollowSystem = useCallback( |
569 | | - (nextFollowSystem: boolean): boolean => { |
570 | | - const currentMode = readAppearanceModePreference(theme); |
571 | | - const nextMode = nextFollowSystem |
572 | | - ? "system" |
573 | | - : currentMode === "system" |
574 | | - ? (getThemePreferenceMode(theme) ?? "light") |
575 | | - : currentMode; |
576 | | - return setAppearanceMode(nextMode); |
577 | | - }, |
578 | | - [setAppearanceMode, theme], |
579 | | - ); |
580 | | - |
581 | | - const setThemeHalf = useCallback( |
582 | | - (appearance: ThemeAppearance, themeId: string | null): boolean => { |
583 | | - if (typeof window === "undefined") return false; |
584 | | - try { |
585 | | - const current = readStoredThemeHalvesRaw(); |
586 | | - const next: { light?: string; dark?: string } = { ...current }; |
587 | | - if (themeId === null) delete next[appearance]; |
588 | | - else next[appearance] = themeId; |
589 | | - if (next.light === undefined && next.dark === undefined) { |
590 | | - window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); |
591 | | - } else { |
592 | | - window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, JSON.stringify(next)); |
593 | | - } |
594 | | - } catch (cause) { |
595 | | - const error = new ThemeStorageError({ |
| 517 | + } catch (cause) { |
| 518 | + const error = isThemeStorageError(cause) |
| 519 | + ? cause |
| 520 | + : new ThemeStorageError({ |
596 | 521 | operation: "write", |
597 | | - storageKey: THEME_HALVES_STORAGE_KEY, |
| 522 | + storageKey: STORAGE_KEY, |
| 523 | + theme: next, |
598 | 524 | cause, |
599 | 525 | }); |
600 | | - console.error(error.message, { |
601 | | - operation: error.operation, |
602 | | - storageKey: error.storageKey, |
603 | | - ...safeErrorLogAttributes(error), |
| 526 | + console.error(error.message, { |
| 527 | + operation: error.operation, |
| 528 | + storageKey: error.storageKey, |
| 529 | + theme: next, |
| 530 | + ...safeErrorLogAttributes(error), |
| 531 | + }); |
| 532 | + return false; |
| 533 | + } |
| 534 | + applyTheme(next, { suppressTransitions: true }); |
| 535 | + emitChange(); |
| 536 | + return true; |
| 537 | +} |
| 538 | + |
| 539 | +function setAppearanceMode(nextAppearanceMode: ThemePreferenceMode): boolean { |
| 540 | + if (typeof window === "undefined") return false; |
| 541 | + try { |
| 542 | + writeAppearanceModePreference(nextAppearanceMode); |
| 543 | + } catch (cause) { |
| 544 | + const error = isThemeStorageError(cause) |
| 545 | + ? cause |
| 546 | + : new ThemeStorageError({ |
| 547 | + operation: "write", |
| 548 | + storageKey: THEME_APPEARANCE_MODE_STORAGE_KEY, |
| 549 | + cause, |
604 | 550 | }); |
605 | | - return false; |
606 | | - } |
607 | | - applyTheme(getStored(), { suppressTransitions: true }); |
608 | | - emitChange(); |
609 | | - return true; |
610 | | - }, |
611 | | - [], |
612 | | - ); |
| 551 | + console.error(error.message, { |
| 552 | + operation: error.operation, |
| 553 | + storageKey: error.storageKey, |
| 554 | + ...safeErrorLogAttributes(error), |
| 555 | + }); |
| 556 | + return false; |
| 557 | + } |
| 558 | + themeStorageReadFailure = null; |
| 559 | + applyTheme(getStored(), { suppressTransitions: true }); |
| 560 | + emitChange(); |
| 561 | + return true; |
| 562 | +} |
613 | 563 |
|
614 | | - const clearThemeHalves = useCallback((): boolean => { |
615 | | - if (typeof window === "undefined") return false; |
616 | | - try { |
| 564 | +function setThemeHalf(appearance: ThemeAppearance, themeId: string | null): boolean { |
| 565 | + if (typeof window === "undefined") return false; |
| 566 | + try { |
| 567 | + const current = readStoredThemeHalvesRaw(); |
| 568 | + const next: { light?: string; dark?: string } = { ...current }; |
| 569 | + if (themeId === null) delete next[appearance]; |
| 570 | + else next[appearance] = themeId; |
| 571 | + if (next.light === undefined && next.dark === undefined) { |
617 | 572 | window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); |
618 | | - } catch (cause) { |
619 | | - const error = new ThemeStorageError({ |
620 | | - operation: "write", |
621 | | - storageKey: THEME_HALVES_STORAGE_KEY, |
622 | | - cause, |
623 | | - }); |
624 | | - console.error(error.message, { |
625 | | - operation: error.operation, |
626 | | - storageKey: error.storageKey, |
627 | | - ...safeErrorLogAttributes(error), |
628 | | - }); |
629 | | - return false; |
| 573 | + } else { |
| 574 | + window.localStorage.setItem(THEME_HALVES_STORAGE_KEY, JSON.stringify(next)); |
630 | 575 | } |
631 | | - applyTheme(getStored(), { suppressTransitions: true }); |
632 | | - emitChange(); |
633 | | - return true; |
634 | | - }, []); |
| 576 | + } catch (cause) { |
| 577 | + const error = new ThemeStorageError({ |
| 578 | + operation: "write", |
| 579 | + storageKey: THEME_HALVES_STORAGE_KEY, |
| 580 | + cause, |
| 581 | + }); |
| 582 | + console.error(error.message, { |
| 583 | + operation: error.operation, |
| 584 | + storageKey: error.storageKey, |
| 585 | + ...safeErrorLogAttributes(error), |
| 586 | + }); |
| 587 | + return false; |
| 588 | + } |
| 589 | + applyTheme(getStored(), { suppressTransitions: true }); |
| 590 | + emitChange(); |
| 591 | + return true; |
| 592 | +} |
635 | 593 |
|
636 | | - const refreshTheme = useCallback(({ preservePreview = false } = {}) => { |
637 | | - if (typeof window === "undefined") return; |
638 | | - lastAppliedTheme = null; |
639 | | - applyTheme(getStored(), { suppressTransitions: true, preservePreview }); |
640 | | - emitChange(); |
641 | | - }, []); |
| 594 | +function clearThemeHalves(): boolean { |
| 595 | + if (typeof window === "undefined") return false; |
| 596 | + try { |
| 597 | + window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); |
| 598 | + } catch (cause) { |
| 599 | + const error = new ThemeStorageError({ |
| 600 | + operation: "write", |
| 601 | + storageKey: THEME_HALVES_STORAGE_KEY, |
| 602 | + cause, |
| 603 | + }); |
| 604 | + console.error(error.message, { |
| 605 | + operation: error.operation, |
| 606 | + storageKey: error.storageKey, |
| 607 | + ...safeErrorLogAttributes(error), |
| 608 | + }); |
| 609 | + return false; |
| 610 | + } |
| 611 | + applyTheme(getStored(), { suppressTransitions: true }); |
| 612 | + emitChange(); |
| 613 | + return true; |
| 614 | +} |
| 615 | + |
| 616 | +function refreshTheme({ preservePreview = false } = {}): void { |
| 617 | + if (typeof window === "undefined") return; |
| 618 | + lastAppliedTheme = null; |
| 619 | + applyTheme(getStored(), { suppressTransitions: true, preservePreview }); |
| 620 | + emitChange(); |
| 621 | +} |
| 622 | + |
| 623 | +export function useTheme() { |
| 624 | + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); |
| 625 | + const { theme, resolvedTheme } = snapshot; |
| 626 | + |
| 627 | + const setFollowSystem = (nextFollowSystem: boolean): boolean => { |
| 628 | + const currentMode = readAppearanceModePreference(theme); |
| 629 | + const nextMode = nextFollowSystem |
| 630 | + ? "system" |
| 631 | + : currentMode === "system" |
| 632 | + ? (getThemePreferenceMode(theme) ?? "light") |
| 633 | + : currentMode; |
| 634 | + return setAppearanceMode(nextMode); |
| 635 | + }; |
642 | 636 |
|
| 637 | + // applyTheme reads the appearance mode and halves from storage, so it keys on the |
| 638 | + // whole snapshot; getSnapshot keeps one object until one of those changes. |
643 | 639 | useEffect(() => { |
644 | | - applyTheme(theme); |
645 | | - }, [snapshot.appearanceMode, theme]); |
| 640 | + applyTheme(snapshot.theme); |
| 641 | + }, [snapshot]); |
646 | 642 |
|
647 | 643 | return { |
648 | 644 | theme, |
|
0 commit comments