Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/hightide-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ 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

- `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

### 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
Expand Down
2 changes: 1 addition & 1 deletion packages/hightide-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,17 @@ export type LocalizationConfig = {
supportedLocales: SupportedLocalesConfig,
}

export type PreferenceStorageConfig = {
shareAcrossSubdomains: boolean,
cookieDomain?: string,
cookiePrefix: string,
}

export type HightideConfig = {
tooltip: TooltipConfig,
theme: ThemeConfig,
localization: LocalizationConfig,
preferenceStorage: PreferenceStorageConfig,
}

const defaultConfig: HightideConfig = {
Expand All @@ -41,6 +48,10 @@ const defaultConfig: HightideConfig = {
fallbackLocale: 'en-US',
supportedLocales: HightideConfigUtils.defaultSupportedLocales,
},
preferenceStorage: {
shareAcrossSubdomains: true,
cookiePrefix: 'hightide-',
},
}

const mergeSupportedThemesConfig = (config: SupportedThemesConfig, overwrite: DeepPartial<SupportedThemesConfig> | undefined): SupportedThemesConfig => {
Expand Down Expand Up @@ -103,6 +114,10 @@ function mergeConfig(config: HightideConfig, overwrite: DeepPartial<HightideConf
overwrite.localization?.supportedLocales
),
},
preferenceStorage: {
...config.preferenceStorage,
...overwrite.preferenceStorage,
},
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './HightideConfigContext'
export * from './HightideConfigUtils'
export * from './usePreferenceStore'
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { SimpleValueStore } from '@helpwave/hightide-utils/hooks'
import { useBrowserKeyValueStore } from '@helpwave/hightide-utils/hooks'
import { useCookieKeyValueStore } from '../../hooks/useCookieKeyValueStore'
import { useHightideConfig } from './HightideConfigContext'

export const usePreferenceStore = (): SimpleValueStore => {
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -21,6 +22,7 @@ export type ThemeProviderProps = PropsWithChildren & Partial<HightideThemeConfig
theme?: ThemeMode | null,
supportedThemes?: SupportedThemesConfig,
onChangedTheme?: (theme: string) => void,
store?: SimpleValueStore,
}

export const ThemeProvider = ({
Expand All @@ -29,9 +31,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(() => {
Expand Down
1 change: 1 addition & 0 deletions packages/hightide-web/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
62 changes: 62 additions & 0 deletions packages/hightide-web/src/hooks/useCookieKeyValueStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
'use client'

import { useEffect, useMemo, useState } from 'react'
import type { SimpleValueStore } from '@helpwave/hightide-utils/hooks'
import { CookieUtils } from '../utils/cookies'

export type UseCookieKeyValueStoreProps = {
domain?: 'auto' | string | null,
prefix?: string,
maxAgeSeconds?: number,
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 [resolved, setResolved] = useState<{ domain: string | undefined } | null>(null)

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 })
if (resolvedDomain) CookieUtils.remove(cookieName(key))
fallbackStore?.deleteValue(key)
}

return {
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
}
CookieUtils.set(cookieName(key), value, { domain: resolvedDomain, maxAgeSeconds })
fallbackStore?.setValue(key, value)
},
deleteValue: (key: string) => {
if (resolved === null) return
deleteValue(key)
},
}
}, [fallbackStore, maxAgeSeconds, prefix, resolved])
}
82 changes: 82 additions & 0 deletions packages/hightide-web/src/utils/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { SafeGlobals } from './safeGlobals'

export type CookieWriteOptions = {
domain?: string,
path?: string,
maxAgeSeconds?: number,
sameSite?: 'Lax' | 'Strict' | 'None',
secure?: boolean,
}

const get = (name: string): string | null => {
const doc = SafeGlobals.document('CookieUtils.get')
if (!doc) return null
const encodedName = encodeURIComponent(name)
for (const part of doc.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,
}: CookieWriteOptions = {}) => {
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 (isSecure || sameSite === 'None') parts.push('Secure')
doc.cookie = parts.join('; ')
}

const remove = (name: string, options: Omit<CookieWriteOptions, 'maxAgeSeconds'> = {}) => {
set(name, '', { ...options, maxAgeSeconds: 0 })
}

const isIpAddress = (hostname: string) => /^[\d.]+$/.test(hostname) || hostname.includes(':')

const registrableDomainCache = new Map<string, string | 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)

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,
}
1 change: 1 addition & 0 deletions packages/hightide-web/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './StorageListener'
export * from './cookies'
export * from './dom'
export * from './forward-exports'
export * from './propsUtil'
Expand Down
Loading
Loading