From ada24fa37ef3bd789f5d476f7f9b86c25befc660 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Fri, 6 Nov 2026 15:55:28 +0100 Subject: [PATCH 1/3] enable setting sharing on tlds --- packages/hightide-utils/CHANGELOG.md | 7 ++ packages/hightide-utils/package.json | 2 +- packages/hightide-utils/src/hooks/index.ts | 1 + .../src/hooks/useCookieKeyValueStore.ts | 68 +++++++++++++ packages/hightide-utils/src/utils/cookie.ts | 97 +++++++++++++++++++ packages/hightide-utils/src/utils/index.ts | 1 + .../hooks/useCookieKeyValueStore.test.ts | 75 ++++++++++++++ packages/hightide-web/CHANGELOG.md | 12 +++ packages/hightide-web/package.json | 2 +- .../hightide-config/HightideConfigContext.tsx | 25 +++++ .../global-contexts/hightide-config/index.ts | 1 + .../hightide-config/usePreferenceStore.ts | 23 +++++ .../localization/LocalizationProvider.tsx | 6 +- .../global-contexts/theme/ThemeProvider.tsx | 9 +- 14 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 packages/hightide-utils/src/hooks/useCookieKeyValueStore.ts create mode 100644 packages/hightide-utils/src/utils/cookie.ts create mode 100644 packages/hightide-utils/tests/hooks/useCookieKeyValueStore.test.ts create mode 100644 packages/hightide-web/src/global-contexts/hightide-config/usePreferenceStore.ts diff --git a/packages/hightide-utils/CHANGELOG.md b/packages/hightide-utils/CHANGELOG.md index 9dfba192..4f023ebc 100644 --- a/packages/hightide-utils/CHANGELOG.md +++ b/packages/hightide-utils/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.10] - unpublished + +### Added + +- `CookieUtils` with `get`, `set`, `remove` and `resolveRegistrableDomain` to find the registrable domain (eTLD+1) of a host +- `useCookieKeyValueStore` storing values in prefixed cookies on a shared domain, with an optional `fallbackStore` for reading and syncing legacy values + ## [0.1.9] - 2026-10-13 ### Added diff --git a/packages/hightide-utils/package.json b/packages/hightide-utils/package.json index cbff3b76..3dde8cbb 100644 --- a/packages/hightide-utils/package.json +++ b/packages/hightide-utils/package.json @@ -10,7 +10,7 @@ "access": "public" }, "license": "AGPL-3.0-only", - "version": "0.1.9", + "version": "0.1.10", "type": "module", "files": [ "dist", diff --git a/packages/hightide-utils/src/hooks/index.ts b/packages/hightide-utils/src/hooks/index.ts index 271a1c2d..8be53671 100644 --- a/packages/hightide-utils/src/hooks/index.ts +++ b/packages/hightide-utils/src/hooks/index.ts @@ -1,6 +1,7 @@ export * from './trees' export * from './useBrowserKeyValueStore' export * from './useChangingNumber' +export * from './useCookieKeyValueStore' export * from './useControlledState' export * from './useDebouncer' export * from './useDelay' diff --git a/packages/hightide-utils/src/hooks/useCookieKeyValueStore.ts b/packages/hightide-utils/src/hooks/useCookieKeyValueStore.ts new file mode 100644 index 00000000..67f1c4c7 --- /dev/null +++ b/packages/hightide-utils/src/hooks/useCookieKeyValueStore.ts @@ -0,0 +1,68 @@ +import { useMemo } from 'react' +import type { SimpleValueStore } from './useSimpleStoreSyncedValue' +import { CookieUtils } from '../utils/cookie' + +export type UseCookieKeyValueStoreProps = { + /** + * The domain the cookies are scoped to. + * - `'auto'` (default): the registrable domain of the current host (e.g. `helpwave.de` on `tasks.helpwave.de`), + * so all subdomains share the same values + * - a string: that exact domain + * - `null`: host-only cookies, nothing is shared with other hosts + */ + domain?: 'auto' | string | null, + /** Prefix for every cookie name to avoid collisions with other cookies on the shared domain. Defaults to `hightide-`. */ + prefix?: string, + /** Lifetime of the cookies in seconds. Defaults to one year. */ + maxAgeSeconds?: number, + /** + * An optional store used as read fallback and kept in sync on writes. + * Useful to migrate values previously persisted in e.g. localStorage. + */ + fallbackStore?: SimpleValueStore, +} + +const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 + +export const useCookieKeyValueStore = ({ + domain = 'auto', + prefix = 'hightide-', + maxAgeSeconds = ONE_YEAR_IN_SECONDS, + fallbackStore, +}: UseCookieKeyValueStoreProps = {}): SimpleValueStore => { + const isClient = typeof document !== 'undefined' + + const resolvedDomain = useMemo(() => { + if (!isClient || domain === null) return undefined + if (domain === 'auto') return CookieUtils.resolveRegistrableDomain() + return domain + }, [domain, isClient]) + + return useMemo(() => { + const cookieName = (key: string) => `${prefix}${key}` + const deleteValue = (key: string) => { + CookieUtils.remove(cookieName(key), { domain: resolvedDomain }) + // Also remove a host-only cookie that might shadow the shared one + if (resolvedDomain) CookieUtils.remove(cookieName(key)) + fallbackStore?.deleteValue(key) + } + + return { + isInitialized: isClient && (fallbackStore?.isInitialized ?? true), + getValue: (key: string) => { + const value = CookieUtils.get(cookieName(key)) + if (value !== null) return value + return fallbackStore?.getValue(key) ?? null + }, + setValue: (key: string, value: string | null) => { + if (value === null) { + deleteValue(key) + return + } + CookieUtils.set(cookieName(key), value, { domain: resolvedDomain, maxAgeSeconds }) + fallbackStore?.setValue(key, value) + }, + deleteValue, + } + }, [fallbackStore, isClient, maxAgeSeconds, prefix, resolvedDomain]) +} diff --git a/packages/hightide-utils/src/utils/cookie.ts b/packages/hightide-utils/src/utils/cookie.ts new file mode 100644 index 00000000..93f6eb9c --- /dev/null +++ b/packages/hightide-utils/src/utils/cookie.ts @@ -0,0 +1,97 @@ +export type CookieWriteOptions = { + /** + * The domain the cookie is scoped to (e.g. `helpwave.de`). + * `undefined` creates a host-only cookie that is only sent to the current host. + */ + domain?: string, + path?: string, + /** Lifetime in seconds. `undefined` creates a session cookie. */ + maxAgeSeconds?: number, + sameSite?: 'Lax' | 'Strict' | 'None', + /** Defaults to `true` when the page is served via https. */ + secure?: boolean, +} + +const hasDocument = () => typeof document !== 'undefined' + +const isSecureContext = () => typeof location !== 'undefined' && location.protocol === 'https:' + +const get = (name: string): string | null => { + if (!hasDocument()) return null + const encodedName = encodeURIComponent(name) + for (const part of document.cookie.split(';')) { + const separatorIndex = part.indexOf('=') + if (separatorIndex === -1) continue + if (part.slice(0, separatorIndex).trim() !== encodedName) continue + try { + return decodeURIComponent(part.slice(separatorIndex + 1).trim()) + } catch { + return null + } + } + return null +} + +const set = (name: string, value: string, { + domain, + path = '/', + maxAgeSeconds, + sameSite = 'Lax', + secure = isSecureContext(), +}: CookieWriteOptions = {}) => { + if (!hasDocument()) return + const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`, `Path=${path}`, `SameSite=${sameSite}`] + if (domain) parts.push(`Domain=${domain}`) + if (maxAgeSeconds !== undefined) parts.push(`Max-Age=${Math.floor(maxAgeSeconds)}`) + if (secure || sameSite === 'None') parts.push('Secure') + document.cookie = parts.join('; ') +} + +const remove = (name: string, options: Omit = {}) => { + set(name, '', { ...options, maxAgeSeconds: 0 }) +} + +const isIpAddress = (hostname: string) => /^[\d.]+$/.test(hostname) || hostname.includes(':') + +const registrableDomainCache = new Map() + +/** + * Finds the broadest domain a cookie can be set on for the given hostname, i.e. the registrable + * domain (eTLD+1), so for `tasks.helpwave.de` this returns `helpwave.de` and for `app.thisismydomain.co.uk` + * it returns `thisismydomain.co.uk`. + * + * Instead of shipping the public suffix list this probes the browser: it tries to set a test cookie on + * increasingly specific domains, the browser rejects public suffixes, so the first accepted domain wins. + * + * Returns `undefined` for `localhost`, IP addresses, single-label hosts or outside the browser, + * in which case a host-only cookie should be used. + */ +const resolveRegistrableDomain = (hostname: string | undefined = typeof location !== 'undefined' ? location.hostname : undefined): string | undefined => { + if (!hasDocument() || !hostname) return undefined + hostname = hostname.toLowerCase().replace(/\.$/, '') + if (registrableDomainCache.has(hostname)) return registrableDomainCache.get(hostname) + + let result: string | undefined = undefined + const labels = hostname.split('.') + if (!isIpAddress(hostname) && labels.length >= 2) { + const probeName = `__hightide_domain_probe_${Math.random().toString(36).slice(2)}` + for (let count = 2; count <= labels.length; count++) { + const candidate = labels.slice(-count).join('.') + set(probeName, '1', { domain: candidate, maxAgeSeconds: 10 }) + if (get(probeName) === '1') { + remove(probeName, { domain: candidate }) + result = candidate + break + } + } + } + registrableDomainCache.set(hostname, result) + return result +} + +export const CookieUtils = { + get, + set, + remove, + resolveRegistrableDomain, +} diff --git a/packages/hightide-utils/src/utils/index.ts b/packages/hightide-utils/src/utils/index.ts index 1cf95af1..0a4a7442 100644 --- a/packages/hightide-utils/src/utils/index.ts +++ b/packages/hightide-utils/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './array' export * from './bagFunctions' export * from './builder' +export * from './cookie' export * from './curve' export * from './date' export * from './duration' diff --git a/packages/hightide-utils/tests/hooks/useCookieKeyValueStore.test.ts b/packages/hightide-utils/tests/hooks/useCookieKeyValueStore.test.ts new file mode 100644 index 00000000..0bbb279d --- /dev/null +++ b/packages/hightide-utils/tests/hooks/useCookieKeyValueStore.test.ts @@ -0,0 +1,75 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url": "https://tasks.helpwave.de/"} + */ + +import { renderHook } from '@testing-library/react' +import { useCookieKeyValueStore } from '../../src/hooks/useCookieKeyValueStore' +import { useBrowserKeyValueStore } from '../../src/hooks/useBrowserKeyValueStore' +import { CookieUtils } from '../../src/utils/cookie' + +const clearCookies = () => { + for (const part of document.cookie.split(';')) { + const name = part.split('=')[0]?.trim() + if (!name) continue + CookieUtils.remove(decodeURIComponent(name), { domain: 'helpwave.de' }) + CookieUtils.remove(decodeURIComponent(name)) + } +} + +describe('CookieUtils.resolveRegistrableDomain', () => { + test('resolves the registrable domain of the current host', () => { + expect(CookieUtils.resolveRegistrableDomain()).toBe('helpwave.de') + }) + + test('returns undefined for localhost and IPs', () => { + expect(CookieUtils.resolveRegistrableDomain('localhost')).toBeUndefined() + expect(CookieUtils.resolveRegistrableDomain('127.0.0.1')).toBeUndefined() + }) +}) + +describe('useCookieKeyValueStore', () => { + beforeEach(() => { + clearCookies() + window.localStorage.clear() + }) + + test('writes prefixed cookies on the shared domain', () => { + const setSpy = jest.spyOn(CookieUtils, 'set') + const { result } = renderHook(() => useCookieKeyValueStore()) + + result.current.setValue('theme', 'dark') + + expect(result.current.getValue('theme')).toBe('dark') + expect(CookieUtils.get('hightide-theme')).toBe('dark') + expect(setSpy).toHaveBeenCalledWith('hightide-theme', 'dark', expect.objectContaining({ domain: 'helpwave.de' })) + setSpy.mockRestore() + }) + + test('uses host-only cookies when domain is null', () => { + const setSpy = jest.spyOn(CookieUtils, 'set') + const { result } = renderHook(() => useCookieKeyValueStore({ domain: null })) + + result.current.setValue('locale', 'de-DE') + + expect(result.current.getValue('locale')).toBe('de-DE') + expect(setSpy).toHaveBeenCalledWith('hightide-locale', 'de-DE', expect.objectContaining({ domain: undefined })) + setSpy.mockRestore() + }) + + test('falls back to and syncs with the fallback store', () => { + window.localStorage.setItem('locale', 'de-DE') + const { result: browser } = renderHook(() => useBrowserKeyValueStore()) + const { result } = renderHook(() => useCookieKeyValueStore({ fallbackStore: browser.current })) + + expect(result.current.getValue('locale')).toBe('de-DE') + + result.current.setValue('locale', 'en-US') + expect(CookieUtils.get('hightide-locale')).toBe('en-US') + expect(window.localStorage.getItem('locale')).toBe('en-US') + + result.current.deleteValue('locale') + expect(result.current.getValue('locale')).toBeNull() + expect(window.localStorage.getItem('locale')).toBeNull() + }) +}) diff --git a/packages/hightide-web/CHANGELOG.md b/packages/hightide-web/CHANGELOG.md index 0a3a15fa..06d8b69a 100644 --- a/packages/hightide-web/CHANGELOG.md +++ b/packages/hightide-web/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.19.0] - unpublished + +### Added + +- `preferenceStorage` config (`shareAcrossSubdomains`, `cookieDomain`, `cookiePrefix`) on `HightideConfig` +- `usePreferenceStore` returning the store used for persisting user preferences +- `ThemeProvider` `store` prop to override the store used for the theme + +### Changed + +- Theme and localization preferences are stored in cookies on the registrable domain by default (e.g. `helpwave.de`), so all subdomains share them; localStorage is kept as fallback and migrated automatically. Disable with `preferenceStorage.shareAcrossSubdomains: false` + ## [0.18.0] - 2026-10-13 ### Added diff --git a/packages/hightide-web/package.json b/packages/hightide-web/package.json index 03e43a8d..3c9f840c 100644 --- a/packages/hightide-web/package.json +++ b/packages/hightide-web/package.json @@ -10,7 +10,7 @@ "access": "public" }, "license": "AGPL-3.0-only", - "version": "0.18.0", + "version": "0.19.0", "browserslist": [ "> 0.2%", "last 3 versions", diff --git a/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx b/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx index 58a9e7eb..a9cd9649 100644 --- a/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx +++ b/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx @@ -21,10 +21,27 @@ export type LocalizationConfig = { supportedLocales: SupportedLocalesConfig, } +export type PreferenceStorageConfig = { + /** + * Whether user preferences (theme, locale, ...) are stored in cookies on the registrable domain + * (e.g. `helpwave.de`), so all subdomains like `tasks.helpwave.de` and `id.helpwave.de` share them. + * When disabled, preferences are only stored in the localStorage of the current origin. + */ + shareAcrossSubdomains: boolean, + /** + * Overrides the automatically detected cookie domain, e.g. `helpwave.de`. + * Only used when `shareAcrossSubdomains` is enabled. + */ + cookieDomain?: string, + /** Prefix for the cookie names. */ + cookiePrefix: string, +} + export type HightideConfig = { tooltip: TooltipConfig, theme: ThemeConfig, localization: LocalizationConfig, + preferenceStorage: PreferenceStorageConfig, } const defaultConfig: HightideConfig = { @@ -41,6 +58,10 @@ const defaultConfig: HightideConfig = { fallbackLocale: 'en-US', supportedLocales: HightideConfigUtils.defaultSupportedLocales, }, + preferenceStorage: { + shareAcrossSubdomains: true, + cookiePrefix: 'hightide-', + }, } const mergeSupportedThemesConfig = (config: SupportedThemesConfig, overwrite: DeepPartial | undefined): SupportedThemesConfig => { @@ -103,6 +124,10 @@ function mergeConfig(config: HightideConfig, overwrite: DeepPartial { + const { config } = useHightideConfig() + const { shareAcrossSubdomains, cookieDomain, cookiePrefix } = config.preferenceStorage + const browserStore = useBrowserKeyValueStore() + const cookieStore = useCookieKeyValueStore({ + domain: cookieDomain ?? 'auto', + prefix: cookiePrefix, + fallbackStore: browserStore, + }) + return shareAcrossSubdomains ? cookieStore : browserStore +} diff --git a/packages/hightide-web/src/global-contexts/localization/LocalizationProvider.tsx b/packages/hightide-web/src/global-contexts/localization/LocalizationProvider.tsx index a0625132..2bb14b0a 100644 --- a/packages/hightide-web/src/global-contexts/localization/LocalizationProvider.tsx +++ b/packages/hightide-web/src/global-contexts/localization/LocalizationProvider.tsx @@ -4,10 +4,10 @@ import { type SupportedLocalesConfig, type LocalizationProviderProps as LocalizationProviderPropsBase } from '@helpwave/hightide-utils/context/localization' -import { useBrowserKeyValueStore } from '@helpwave/hightide-utils/hooks' import { LocalizationUtils } from '@helpwave/hightide-utils/utils' import { useEffect, useState } from 'react' import { useHightideConfig } from '../hightide-config/HightideConfigContext' +import { usePreferenceStore } from '../hightide-config/usePreferenceStore' import { SafeGlobals } from '../../utils/safeGlobals' export type LocalizationProviderProps = PropsWithChildren @@ -51,8 +51,8 @@ export const LocalizationProvider = ({ store: storeOverride, ...rest }: LocalizationProviderProps) => { - const browserStore = useBrowserKeyValueStore() - const store = storeOverride ?? browserStore + const preferenceStore = usePreferenceStore() + const store = storeOverride ?? preferenceStore const { config } = useHightideConfig() const resolvedSupportedLocales = supportedLocales ?? config.localization.supportedLocales const detectedSystemLocale = useWebSystemLocale(resolvedSupportedLocales) diff --git a/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx b/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx index 33242a2c..d20a304f 100644 --- a/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx +++ b/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx @@ -1,8 +1,9 @@ import type { PropsWithChildren } from 'react' import { useEffect, useMemo } from 'react' -import { useBrowserKeyValueStore } from '@helpwave/hightide-utils/hooks' +import type { SimpleValueStore } from '@helpwave/hightide-utils/hooks' import type { ThemeConfig as HightideThemeConfig } from '../hightide-config/HightideConfigContext' import { useHightideConfig } from '../hightide-config/HightideConfigContext' +import { usePreferenceStore } from '../hightide-config/usePreferenceStore' import { useWebSystemTheme } from '../../hooks/useWebSystemTheme' import type { ThemeMode, @@ -21,6 +22,8 @@ export type ThemeProviderProps = PropsWithChildren & Partial void, + /** Overrides the store used to persist the theme. Defaults to the preference store from the config. */ + store?: SimpleValueStore, } export const ThemeProvider = ({ @@ -29,9 +32,11 @@ export const ThemeProvider = ({ initialTheme, supportedThemes, onChangedTheme, + store: storeOverride, }: ThemeProviderProps) => { const { config } = useHightideConfig() - const store = useBrowserKeyValueStore() + const preferenceStore = usePreferenceStore() + const store = storeOverride ?? preferenceStore const systemTheme = useWebSystemTheme() const resolvedSupportedThemes = supportedThemes ?? config.theme.supportedThemes const mappedSupportedThemes = useMemo(() => { From 9b700c04e705b22c42f3b1592da306e9c2714355 Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Fri, 6 Nov 2026 16:35:28 +0100 Subject: [PATCH 2/3] switch to platform agnostic version --- packages/hightide-utils/CHANGELOG.md | 7 ---- packages/hightide-utils/package.json | 2 +- packages/hightide-utils/src/hooks/index.ts | 1 - packages/hightide-utils/src/utils/index.ts | 1 - packages/hightide-web/CHANGELOG.md | 2 + .../hightide-config/usePreferenceStore.ts | 3 +- packages/hightide-web/src/hooks/index.ts | 1 + .../src/hooks/useCookieKeyValueStore.ts | 38 +++++++++++++------ .../src/utils/cookies.ts} | 28 ++++++++------ packages/hightide-web/src/utils/index.ts | 1 + .../storage}/useCookieKeyValueStore.test.ts | 32 +++++++++++++++- 11 files changed, 80 insertions(+), 36 deletions(-) rename packages/{hightide-utils => hightide-web}/src/hooks/useCookieKeyValueStore.ts (65%) rename packages/{hightide-utils/src/utils/cookie.ts => hightide-web/src/utils/cookies.ts} (80%) rename packages/{hightide-utils/tests/hooks => hightide-web/tests/storage}/useCookieKeyValueStore.test.ts (72%) diff --git a/packages/hightide-utils/CHANGELOG.md b/packages/hightide-utils/CHANGELOG.md index 4f023ebc..9dfba192 100644 --- a/packages/hightide-utils/CHANGELOG.md +++ b/packages/hightide-utils/CHANGELOG.md @@ -5,13 +5,6 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.1.10] - unpublished - -### Added - -- `CookieUtils` with `get`, `set`, `remove` and `resolveRegistrableDomain` to find the registrable domain (eTLD+1) of a host -- `useCookieKeyValueStore` storing values in prefixed cookies on a shared domain, with an optional `fallbackStore` for reading and syncing legacy values - ## [0.1.9] - 2026-10-13 ### Added diff --git a/packages/hightide-utils/package.json b/packages/hightide-utils/package.json index 3dde8cbb..cbff3b76 100644 --- a/packages/hightide-utils/package.json +++ b/packages/hightide-utils/package.json @@ -10,7 +10,7 @@ "access": "public" }, "license": "AGPL-3.0-only", - "version": "0.1.10", + "version": "0.1.9", "type": "module", "files": [ "dist", diff --git a/packages/hightide-utils/src/hooks/index.ts b/packages/hightide-utils/src/hooks/index.ts index 8be53671..271a1c2d 100644 --- a/packages/hightide-utils/src/hooks/index.ts +++ b/packages/hightide-utils/src/hooks/index.ts @@ -1,7 +1,6 @@ export * from './trees' export * from './useBrowserKeyValueStore' export * from './useChangingNumber' -export * from './useCookieKeyValueStore' export * from './useControlledState' export * from './useDebouncer' export * from './useDelay' diff --git a/packages/hightide-utils/src/utils/index.ts b/packages/hightide-utils/src/utils/index.ts index 0a4a7442..1cf95af1 100644 --- a/packages/hightide-utils/src/utils/index.ts +++ b/packages/hightide-utils/src/utils/index.ts @@ -1,7 +1,6 @@ export * from './array' export * from './bagFunctions' export * from './builder' -export * from './cookie' export * from './curve' export * from './date' export * from './duration' diff --git a/packages/hightide-web/CHANGELOG.md b/packages/hightide-web/CHANGELOG.md index 06d8b69a..8d167f1a 100644 --- a/packages/hightide-web/CHANGELOG.md +++ b/packages/hightide-web/CHANGELOG.md @@ -9,6 +9,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- `CookieUtils` with `get`, `set`, `remove` and `resolveRegistrableDomain` to find the registrable domain (eTLD+1) of a host +- `useCookieKeyValueStore` storing values in prefixed cookies on a shared domain, with an optional `fallbackStore` for reading and syncing legacy values; the domain is resolved after mounting to stay hydration safe - `preferenceStorage` config (`shareAcrossSubdomains`, `cookieDomain`, `cookiePrefix`) on `HightideConfig` - `usePreferenceStore` returning the store used for persisting user preferences - `ThemeProvider` `store` prop to override the store used for the theme diff --git a/packages/hightide-web/src/global-contexts/hightide-config/usePreferenceStore.ts b/packages/hightide-web/src/global-contexts/hightide-config/usePreferenceStore.ts index 88b9e9e8..4b07cfd5 100644 --- a/packages/hightide-web/src/global-contexts/hightide-config/usePreferenceStore.ts +++ b/packages/hightide-web/src/global-contexts/hightide-config/usePreferenceStore.ts @@ -1,5 +1,6 @@ import type { SimpleValueStore } from '@helpwave/hightide-utils/hooks' -import { useBrowserKeyValueStore, useCookieKeyValueStore } from '@helpwave/hightide-utils/hooks' +import { useBrowserKeyValueStore } from '@helpwave/hightide-utils/hooks' +import { useCookieKeyValueStore } from '../../hooks/useCookieKeyValueStore' import { useHightideConfig } from './HightideConfigContext' /** diff --git a/packages/hightide-web/src/hooks/index.ts b/packages/hightide-web/src/hooks/index.ts index ad6dc3c7..d4c1208a 100644 --- a/packages/hightide-web/src/hooks/index.ts +++ b/packages/hightide-web/src/hooks/index.ts @@ -1,6 +1,7 @@ export * from './focus' export * from './forward-exports' export * from './useAnchoredPosition' +export * from './useCookieKeyValueStore' export * from './useHandleRefs' export * from './useIsMounted' export * from './useOutsideClick' diff --git a/packages/hightide-utils/src/hooks/useCookieKeyValueStore.ts b/packages/hightide-web/src/hooks/useCookieKeyValueStore.ts similarity index 65% rename from packages/hightide-utils/src/hooks/useCookieKeyValueStore.ts rename to packages/hightide-web/src/hooks/useCookieKeyValueStore.ts index 67f1c4c7..38cbc0e4 100644 --- a/packages/hightide-utils/src/hooks/useCookieKeyValueStore.ts +++ b/packages/hightide-web/src/hooks/useCookieKeyValueStore.ts @@ -1,6 +1,8 @@ -import { useMemo } from 'react' -import type { SimpleValueStore } from './useSimpleStoreSyncedValue' -import { CookieUtils } from '../utils/cookie' +'use client' + +import { useEffect, useMemo, useState } from 'react' +import type { SimpleValueStore } from '@helpwave/hightide-utils/hooks' +import { CookieUtils } from '../utils/cookies' export type UseCookieKeyValueStoreProps = { /** @@ -24,21 +26,28 @@ export type UseCookieKeyValueStoreProps = { const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 +/** + * A store persisting values in cookies. + * + * The cookie domain is resolved after mounting, until then `isInitialized` is `false`, + * so consumers like `useSimpleStoreSyncedValue` do not read or write during rendering and hydration. + */ export const useCookieKeyValueStore = ({ domain = 'auto', prefix = 'hightide-', maxAgeSeconds = ONE_YEAR_IN_SECONDS, fallbackStore, }: UseCookieKeyValueStoreProps = {}): SimpleValueStore => { - const isClient = typeof document !== 'undefined' + const [resolved, setResolved] = useState<{ domain: string | undefined } | null>(null) - const resolvedDomain = useMemo(() => { - if (!isClient || domain === null) return undefined - if (domain === 'auto') return CookieUtils.resolveRegistrableDomain() - return domain - }, [domain, isClient]) + useEffect(() => { + setResolved({ + domain: domain === 'auto' ? CookieUtils.resolveRegistrableDomain() : (domain ?? undefined), + }) + }, [domain]) return useMemo(() => { + const resolvedDomain = resolved?.domain const cookieName = (key: string) => `${prefix}${key}` const deleteValue = (key: string) => { CookieUtils.remove(cookieName(key), { domain: resolvedDomain }) @@ -48,13 +57,15 @@ export const useCookieKeyValueStore = ({ } return { - isInitialized: isClient && (fallbackStore?.isInitialized ?? true), + isInitialized: resolved !== null && (fallbackStore?.isInitialized ?? true), getValue: (key: string) => { + if (resolved === null) return null const value = CookieUtils.get(cookieName(key)) if (value !== null) return value return fallbackStore?.getValue(key) ?? null }, setValue: (key: string, value: string | null) => { + if (resolved === null) return if (value === null) { deleteValue(key) return @@ -62,7 +73,10 @@ export const useCookieKeyValueStore = ({ CookieUtils.set(cookieName(key), value, { domain: resolvedDomain, maxAgeSeconds }) fallbackStore?.setValue(key, value) }, - deleteValue, + deleteValue: (key: string) => { + if (resolved === null) return + deleteValue(key) + }, } - }, [fallbackStore, isClient, maxAgeSeconds, prefix, resolvedDomain]) + }, [fallbackStore, maxAgeSeconds, prefix, resolved]) } diff --git a/packages/hightide-utils/src/utils/cookie.ts b/packages/hightide-web/src/utils/cookies.ts similarity index 80% rename from packages/hightide-utils/src/utils/cookie.ts rename to packages/hightide-web/src/utils/cookies.ts index 93f6eb9c..2f3f989b 100644 --- a/packages/hightide-utils/src/utils/cookie.ts +++ b/packages/hightide-web/src/utils/cookies.ts @@ -1,3 +1,5 @@ +import { SafeGlobals } from './safeGlobals' + export type CookieWriteOptions = { /** * The domain the cookie is scoped to (e.g. `helpwave.de`). @@ -12,14 +14,15 @@ export type CookieWriteOptions = { secure?: boolean, } -const hasDocument = () => typeof document !== 'undefined' - -const isSecureContext = () => typeof location !== 'undefined' && location.protocol === 'https:' +/* + * All functions access DOM globals and must only be called in safe locations like `useEffect` or event handlers. + */ const get = (name: string): string | null => { - if (!hasDocument()) return null + const doc = SafeGlobals.document('CookieUtils.get') + if (!doc) return null const encodedName = encodeURIComponent(name) - for (const part of document.cookie.split(';')) { + for (const part of doc.cookie.split(';')) { const separatorIndex = part.indexOf('=') if (separatorIndex === -1) continue if (part.slice(0, separatorIndex).trim() !== encodedName) continue @@ -37,14 +40,16 @@ const set = (name: string, value: string, { path = '/', maxAgeSeconds, sameSite = 'Lax', - secure = isSecureContext(), + secure, }: CookieWriteOptions = {}) => { - if (!hasDocument()) return + const doc = SafeGlobals.document('CookieUtils.set') + if (!doc) return + const isSecure = secure ?? doc.location?.protocol === 'https:' const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`, `Path=${path}`, `SameSite=${sameSite}`] if (domain) parts.push(`Domain=${domain}`) if (maxAgeSeconds !== undefined) parts.push(`Max-Age=${Math.floor(maxAgeSeconds)}`) - if (secure || sameSite === 'None') parts.push('Secure') - document.cookie = parts.join('; ') + if (isSecure || sameSite === 'None') parts.push('Secure') + doc.cookie = parts.join('; ') } const remove = (name: string, options: Omit = {}) => { @@ -66,8 +71,9 @@ const registrableDomainCache = new Map() * Returns `undefined` for `localhost`, IP addresses, single-label hosts or outside the browser, * in which case a host-only cookie should be used. */ -const resolveRegistrableDomain = (hostname: string | undefined = typeof location !== 'undefined' ? location.hostname : undefined): string | undefined => { - if (!hasDocument() || !hostname) return undefined +const resolveRegistrableDomain = (hostname?: string): string | undefined => { + hostname ??= SafeGlobals.window('CookieUtils.resolveRegistrableDomain')?.location.hostname + if (!hostname) return undefined hostname = hostname.toLowerCase().replace(/\.$/, '') if (registrableDomainCache.has(hostname)) return registrableDomainCache.get(hostname) diff --git a/packages/hightide-web/src/utils/index.ts b/packages/hightide-web/src/utils/index.ts index 6637c26a..f34a7731 100644 --- a/packages/hightide-web/src/utils/index.ts +++ b/packages/hightide-web/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './StorageListener' +export * from './cookies' export * from './dom' export * from './forward-exports' export * from './propsUtil' diff --git a/packages/hightide-utils/tests/hooks/useCookieKeyValueStore.test.ts b/packages/hightide-web/tests/storage/useCookieKeyValueStore.test.ts similarity index 72% rename from packages/hightide-utils/tests/hooks/useCookieKeyValueStore.test.ts rename to packages/hightide-web/tests/storage/useCookieKeyValueStore.test.ts index 0bbb279d..717a15bb 100644 --- a/packages/hightide-utils/tests/hooks/useCookieKeyValueStore.test.ts +++ b/packages/hightide-web/tests/storage/useCookieKeyValueStore.test.ts @@ -3,10 +3,12 @@ * @jest-environment-options {"url": "https://tasks.helpwave.de/"} */ +import { createElement } from 'react' +import { renderToString } from 'react-dom/server' import { renderHook } from '@testing-library/react' +import { useBrowserKeyValueStore } from '@helpwave/hightide-utils/hooks' import { useCookieKeyValueStore } from '../../src/hooks/useCookieKeyValueStore' -import { useBrowserKeyValueStore } from '../../src/hooks/useBrowserKeyValueStore' -import { CookieUtils } from '../../src/utils/cookie' +import { CookieUtils } from '../../src/utils/cookies' const clearCookies = () => { for (const part of document.cookie.split(';')) { @@ -34,6 +36,32 @@ describe('useCookieKeyValueStore', () => { window.localStorage.clear() }) + test('is not initialized and does not touch cookies before mounting', () => { + const getSpy = jest.spyOn(CookieUtils, 'get') + const resolveSpy = jest.spyOn(CookieUtils, 'resolveRegistrableDomain') + const states: boolean[] = [] + const Probe = () => { + const store = useCookieKeyValueStore() + states.push(store.isInitialized) + store.getValue('theme') + return null + } + + renderToString(createElement(Probe)) + + expect(states).toEqual([false]) + expect(getSpy).not.toHaveBeenCalled() + expect(resolveSpy).not.toHaveBeenCalled() + getSpy.mockRestore() + resolveSpy.mockRestore() + }) + + test('is initialized after mounting', () => { + const { result } = renderHook(() => useCookieKeyValueStore()) + + expect(result.current.isInitialized).toBe(true) + }) + test('writes prefixed cookies on the shared domain', () => { const setSpy = jest.spyOn(CookieUtils, 'set') const { result } = renderHook(() => useCookieKeyValueStore()) From 5799f59c0513f25b2b1c43e332e703ad623377cc Mon Sep 17 00:00:00 2001 From: Felix Evers Date: Fri, 6 Nov 2026 16:41:05 +0100 Subject: [PATCH 3/3] remove comments --- .../hightide-config/HightideConfigContext.tsx | 10 --------- .../hightide-config/usePreferenceStore.ts | 8 ------- .../global-contexts/theme/ThemeProvider.tsx | 1 - .../src/hooks/useCookieKeyValueStore.ts | 20 ------------------ packages/hightide-web/src/utils/cookies.ts | 21 ------------------- 5 files changed, 60 deletions(-) diff --git a/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx b/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx index a9cd9649..eeaf293b 100644 --- a/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx +++ b/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx @@ -22,18 +22,8 @@ export type LocalizationConfig = { } export type PreferenceStorageConfig = { - /** - * Whether user preferences (theme, locale, ...) are stored in cookies on the registrable domain - * (e.g. `helpwave.de`), so all subdomains like `tasks.helpwave.de` and `id.helpwave.de` share them. - * When disabled, preferences are only stored in the localStorage of the current origin. - */ shareAcrossSubdomains: boolean, - /** - * Overrides the automatically detected cookie domain, e.g. `helpwave.de`. - * Only used when `shareAcrossSubdomains` is enabled. - */ cookieDomain?: string, - /** Prefix for the cookie names. */ cookiePrefix: string, } diff --git a/packages/hightide-web/src/global-contexts/hightide-config/usePreferenceStore.ts b/packages/hightide-web/src/global-contexts/hightide-config/usePreferenceStore.ts index 4b07cfd5..bd8b97a9 100644 --- a/packages/hightide-web/src/global-contexts/hightide-config/usePreferenceStore.ts +++ b/packages/hightide-web/src/global-contexts/hightide-config/usePreferenceStore.ts @@ -3,14 +3,6 @@ import { useBrowserKeyValueStore } from '@helpwave/hightide-utils/hooks' import { useCookieKeyValueStore } from '../../hooks/useCookieKeyValueStore' import { useHightideConfig } from './HightideConfigContext' -/** - * The store used for persisting user preferences like theme and locale. - * - * By default, values are stored in cookies on the registrable domain so that all subdomains - * (e.g. `tasks.helpwave.de`, `id.helpwave.de`) share them. localStorage is kept as fallback and in sync, - * which also migrates previously stored values. - * Set `preferenceStorage.shareAcrossSubdomains` to `false` in the config to only use localStorage. - */ export const usePreferenceStore = (): SimpleValueStore => { const { config } = useHightideConfig() const { shareAcrossSubdomains, cookieDomain, cookiePrefix } = config.preferenceStorage diff --git a/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx b/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx index d20a304f..c6095081 100644 --- a/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx +++ b/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx @@ -22,7 +22,6 @@ export type ThemeProviderProps = PropsWithChildren & Partial void, - /** Overrides the store used to persist the theme. Defaults to the preference store from the config. */ store?: SimpleValueStore, } diff --git a/packages/hightide-web/src/hooks/useCookieKeyValueStore.ts b/packages/hightide-web/src/hooks/useCookieKeyValueStore.ts index 38cbc0e4..21a1358a 100644 --- a/packages/hightide-web/src/hooks/useCookieKeyValueStore.ts +++ b/packages/hightide-web/src/hooks/useCookieKeyValueStore.ts @@ -5,33 +5,14 @@ import type { SimpleValueStore } from '@helpwave/hightide-utils/hooks' import { CookieUtils } from '../utils/cookies' export type UseCookieKeyValueStoreProps = { - /** - * The domain the cookies are scoped to. - * - `'auto'` (default): the registrable domain of the current host (e.g. `helpwave.de` on `tasks.helpwave.de`), - * so all subdomains share the same values - * - a string: that exact domain - * - `null`: host-only cookies, nothing is shared with other hosts - */ domain?: 'auto' | string | null, - /** Prefix for every cookie name to avoid collisions with other cookies on the shared domain. Defaults to `hightide-`. */ prefix?: string, - /** Lifetime of the cookies in seconds. Defaults to one year. */ maxAgeSeconds?: number, - /** - * An optional store used as read fallback and kept in sync on writes. - * Useful to migrate values previously persisted in e.g. localStorage. - */ fallbackStore?: SimpleValueStore, } const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 -/** - * A store persisting values in cookies. - * - * The cookie domain is resolved after mounting, until then `isInitialized` is `false`, - * so consumers like `useSimpleStoreSyncedValue` do not read or write during rendering and hydration. - */ export const useCookieKeyValueStore = ({ domain = 'auto', prefix = 'hightide-', @@ -51,7 +32,6 @@ export const useCookieKeyValueStore = ({ const cookieName = (key: string) => `${prefix}${key}` const deleteValue = (key: string) => { CookieUtils.remove(cookieName(key), { domain: resolvedDomain }) - // Also remove a host-only cookie that might shadow the shared one if (resolvedDomain) CookieUtils.remove(cookieName(key)) fallbackStore?.deleteValue(key) } diff --git a/packages/hightide-web/src/utils/cookies.ts b/packages/hightide-web/src/utils/cookies.ts index 2f3f989b..fbef5109 100644 --- a/packages/hightide-web/src/utils/cookies.ts +++ b/packages/hightide-web/src/utils/cookies.ts @@ -1,23 +1,13 @@ import { SafeGlobals } from './safeGlobals' export type CookieWriteOptions = { - /** - * The domain the cookie is scoped to (e.g. `helpwave.de`). - * `undefined` creates a host-only cookie that is only sent to the current host. - */ domain?: string, path?: string, - /** Lifetime in seconds. `undefined` creates a session cookie. */ maxAgeSeconds?: number, sameSite?: 'Lax' | 'Strict' | 'None', - /** Defaults to `true` when the page is served via https. */ secure?: boolean, } -/* - * All functions access DOM globals and must only be called in safe locations like `useEffect` or event handlers. - */ - const get = (name: string): string | null => { const doc = SafeGlobals.document('CookieUtils.get') if (!doc) return null @@ -60,17 +50,6 @@ const isIpAddress = (hostname: string) => /^[\d.]+$/.test(hostname) || hostname. const registrableDomainCache = new Map() -/** - * Finds the broadest domain a cookie can be set on for the given hostname, i.e. the registrable - * domain (eTLD+1), so for `tasks.helpwave.de` this returns `helpwave.de` and for `app.thisismydomain.co.uk` - * it returns `thisismydomain.co.uk`. - * - * Instead of shipping the public suffix list this probes the browser: it tries to set a test cookie on - * increasingly specific domains, the browser rejects public suffixes, so the first accepted domain wins. - * - * Returns `undefined` for `localhost`, IP addresses, single-label hosts or outside the browser, - * in which case a host-only cookie should be used. - */ const resolveRegistrableDomain = (hostname?: string): string | undefined => { hostname ??= SafeGlobals.window('CookieUtils.resolveRegistrableDomain')?.location.hostname if (!hostname) return undefined