feat(storage): enhance useStorageAsync with cross-instance sync and event handling
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
export * from './useCookie';
|
||||
export * from './useLocalStorage';
|
||||
export * from './useSessionStorage';
|
||||
export * from './useStorage';
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from 'vue';
|
||||
import { useCookie } from './index';
|
||||
|
||||
// One reactive cookie for the consent preferences. In browsers with the
|
||||
// Cookie Store API changes from other tabs (and even server Set-Cookie
|
||||
// responses) sync automatically; elsewhere writes still sync between
|
||||
// components on this page.
|
||||
const { state: consent, isReady } = useCookie('demo:consent', {
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
}, { maxAge: 60 * 60 * 24 });
|
||||
|
||||
// A second instance bound to the same cookie — flips in sync with the first.
|
||||
// writeDefaults is off so this read-only mirror never races the first
|
||||
// instance's attributes (it has no maxAge of its own).
|
||||
const { state: mirror } = useCookie('demo:consent', {
|
||||
analytics: false,
|
||||
marketing: false,
|
||||
}, { writeDefaults: false });
|
||||
|
||||
const categories = [
|
||||
{ key: 'analytics', label: 'Analytics', hint: 'Usage metrics' },
|
||||
{ key: 'marketing', label: 'Marketing', hint: 'Personalized ads' },
|
||||
] as const;
|
||||
|
||||
function toggle(key: (typeof categories)[number]['key']) {
|
||||
consent.value = { ...consent.value, [key]: !consent.value[key] };
|
||||
}
|
||||
|
||||
const supportsCookieStore = typeof window !== 'undefined' && 'cookieStore' in window;
|
||||
|
||||
// Show the raw cookie as the browser stores it.
|
||||
const rawCookie = ref('');
|
||||
|
||||
watchEffect(() => {
|
||||
void consent.value;
|
||||
void mirror.value;
|
||||
|
||||
if (typeof document !== 'undefined')
|
||||
rawCookie.value = document.cookie.split('; ').find(part => part.startsWith('demo%3Aconsent=')) ?? '(no cookie)';
|
||||
});
|
||||
|
||||
const accepted = computed(() => Object.values(consent.value).filter(Boolean).length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-sm flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Cookie consent</span>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="isReady ? 'bg-emerald-500' : 'bg-(--fg-subtle) animate-pulse'" />
|
||||
{{ supportsCookieStore ? 'Cookie Store API' : 'document.cookie' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-3">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.key"
|
||||
type="button"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition active:scale-[0.99] cursor-pointer"
|
||||
:class="consent[category.key]
|
||||
? 'border-transparent bg-(--accent)/10'
|
||||
: 'border-(--border) bg-(--bg-inset) hover:border-(--border-strong)'"
|
||||
@click="toggle(category.key)"
|
||||
>
|
||||
<span class="flex flex-col">
|
||||
<span class="text-sm font-medium text-(--fg)">{{ category.label }}</span>
|
||||
<span class="text-xs text-(--fg-subtle)">{{ category.hint }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="relative h-5 w-9 shrink-0 rounded-full transition"
|
||||
:class="consent[category.key] ? 'bg-(--accent)' : 'bg-(--border-strong)'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all"
|
||||
:class="consent[category.key] ? 'left-4.5' : 'left-0.5'"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 flex flex-col gap-2 font-mono text-xs text-(--fg)">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-(--fg-subtle)">second instance</span>
|
||||
<span>{{ accepted }}/{{ categories.length }} accepted · {{ JSON.stringify(mirror) }}</span>
|
||||
</div>
|
||||
<div class="truncate text-(--fg-muted)" :title="rawCookie">
|
||||
{{ rawCookie }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--fg-subtle)">
|
||||
Both cards bind to the same cookie (24h Max-Age) — toggling one updates the other through cookie change events.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,738 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { effectScope, nextTick, ref } from 'vue';
|
||||
import { useCookie } from '.';
|
||||
import type { CookieStorageLike } from '.';
|
||||
|
||||
function flushWrites() {
|
||||
return new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function rawCookie(name: string): string | null {
|
||||
const pair = document.cookie.split('; ').find(part => part.startsWith(`${name}=`));
|
||||
|
||||
return pair ? pair.slice(name.length + 1) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory `document` for asserting the exact strings assigned to
|
||||
* `document.cookie` (jsdom normalizes attributes away on read-back).
|
||||
*/
|
||||
function createFakeDocument() {
|
||||
const jar = new Map<string, string>();
|
||||
const writes: string[] = [];
|
||||
|
||||
return {
|
||||
writes,
|
||||
document: {
|
||||
get cookie() {
|
||||
return [...jar].map(([name, value]) => `${name}=${value}`).join('; ');
|
||||
},
|
||||
set cookie(input: string) {
|
||||
writes.push(input);
|
||||
|
||||
const [pair = ''] = input.split(';');
|
||||
const separator = pair.indexOf('=');
|
||||
const name = pair.slice(0, separator);
|
||||
const value = pair.slice(separator + 1);
|
||||
|
||||
if (/Max-Age=(?:0|-\d+)/i.test(input))
|
||||
jar.delete(name);
|
||||
else
|
||||
jar.set(name, value);
|
||||
},
|
||||
} as unknown as Document,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal Cookie Store API fake: same `get`/`set`/`delete` surface, fires the
|
||||
* `change` event as a macrotask after each commit (like real browsers).
|
||||
*/
|
||||
class FakeCookieStore extends EventTarget {
|
||||
jar = new Map<string, string>();
|
||||
setCalls: CookieInit[] = [];
|
||||
deleteCalls: CookieStoreDeleteOptions[] = [];
|
||||
|
||||
async get(name: string): Promise<CookieListItem | null> {
|
||||
return this.jar.has(name) ? { name, value: this.jar.get(name)! } : null;
|
||||
}
|
||||
|
||||
async set(init: CookieInit): Promise<void> {
|
||||
this.setCalls.push(init);
|
||||
this.jar.set(init.name, init.value);
|
||||
setTimeout(() => this.fireChange([{ name: init.name, value: init.value }], []), 0);
|
||||
}
|
||||
|
||||
async delete(options: CookieStoreDeleteOptions): Promise<void> {
|
||||
this.deleteCalls.push(options);
|
||||
this.jar.delete(options.name);
|
||||
setTimeout(() => this.fireChange([], [{ name: options.name }]), 0);
|
||||
}
|
||||
|
||||
fireChange(changed: CookieListItem[], deleted: CookieListItem[]) {
|
||||
this.dispatchEvent(Object.assign(new Event('change'), { changed, deleted }));
|
||||
}
|
||||
}
|
||||
|
||||
function createCookieStoreWindow() {
|
||||
const cookieStore = new FakeCookieStore();
|
||||
const fakeWindow = Object.create(globalThis) as Window;
|
||||
|
||||
Object.defineProperty(fakeWindow, 'cookieStore', { value: cookieStore });
|
||||
|
||||
return { cookieStore, window: fakeWindow };
|
||||
}
|
||||
|
||||
describe(useCookie, () => {
|
||||
describe('document.cookie fallback', () => {
|
||||
// --- Basic read/write ---
|
||||
|
||||
it('reads an existing cookie synchronously and is ready immediately', () => {
|
||||
document.cookie = 'uc-read=stored; Path=/';
|
||||
|
||||
const { state, isReady } = useCookie('uc-read', 'default');
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('is awaitable and resolves with the shell', async () => {
|
||||
document.cookie = 'uc-await=stored; Path=/';
|
||||
|
||||
const { state, isReady } = await useCookie('uc-await', 'default');
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('writes the cookie on state change', async () => {
|
||||
const { state } = useCookie<string>('uc-write', 'initial');
|
||||
|
||||
state.value = 'updated';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(rawCookie('uc-write')).toBe('updated');
|
||||
});
|
||||
|
||||
it('deletes the cookie when state is set to null', async () => {
|
||||
document.cookie = 'uc-del=exists; Path=/';
|
||||
|
||||
const { state } = useCookie<string | null>('uc-del', 'default');
|
||||
|
||||
state.value = null;
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(rawCookie('uc-del')).toBeNull();
|
||||
});
|
||||
|
||||
// --- writeDefaults ---
|
||||
|
||||
it('persists the default when the cookie does not exist', async () => {
|
||||
useCookie('uc-defaults', 'fallback');
|
||||
await flushWrites();
|
||||
|
||||
expect(rawCookie('uc-defaults')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('does not persist the default when writeDefaults is false', async () => {
|
||||
useCookie('uc-no-defaults', 'fallback', { writeDefaults: false });
|
||||
await flushWrites();
|
||||
|
||||
expect(rawCookie('uc-no-defaults')).toBeNull();
|
||||
});
|
||||
|
||||
// --- Serialization & encoding ---
|
||||
|
||||
it('round-trips objects through encoding', async () => {
|
||||
const { state } = useCookie('uc-obj', { theme: 'dark', items: [1, 2] });
|
||||
|
||||
state.value = { theme: 'light', items: [3] };
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
const { state: other } = useCookie('uc-obj', { theme: 'none', items: [] as number[] });
|
||||
|
||||
expect(other.value).toEqual({ theme: 'light', items: [3] });
|
||||
});
|
||||
|
||||
it('percent-encodes values that cookies cannot contain', async () => {
|
||||
const { state } = useCookie<string>('uc-enc', '');
|
||||
|
||||
state.value = 'a;b c';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(rawCookie('uc-enc')).toBe('a%3Bb%20c');
|
||||
});
|
||||
|
||||
it('encodes the cookie name', async () => {
|
||||
const { state } = useCookie<string>('uc enc name', '');
|
||||
|
||||
state.value = 'v';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(rawCookie('uc%20enc%20name')).toBe('v');
|
||||
});
|
||||
|
||||
it('uses a custom serializer', async () => {
|
||||
document.cookie = 'uc-ser=1,2,3; Path=/';
|
||||
|
||||
const serializer = {
|
||||
read: (v: string) => v.split(',').map(Number),
|
||||
write: (v: number[]) => v.join(','),
|
||||
};
|
||||
|
||||
const { state } = useCookie('uc-ser', [0], { serializer });
|
||||
|
||||
expect(state.value).toEqual([1, 2, 3]);
|
||||
|
||||
state.value = [4, 5];
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
// The comma is percent-encoded at the cookie layer, below the serializer.
|
||||
expect(rawCookie('uc-ser')).toBe('4%2C5');
|
||||
|
||||
const { state: other } = useCookie('uc-ser', [0], { serializer });
|
||||
|
||||
expect(other.value).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
// --- Merge defaults ---
|
||||
|
||||
it('merges defaults with the stored value', () => {
|
||||
document.cookie = `uc-merge=${encodeURIComponent(JSON.stringify({ hello: 'stored' }))}; Path=/`;
|
||||
|
||||
const { state } = useCookie('uc-merge', { hello: 'default', greeting: 'hi' }, { mergeDefaults: true });
|
||||
|
||||
expect(state.value).toEqual({ hello: 'stored', greeting: 'hi' });
|
||||
});
|
||||
|
||||
// --- Attributes ---
|
||||
|
||||
it('applies cookie attributes to every write', async () => {
|
||||
const { writes, document: fakeDocument } = createFakeDocument();
|
||||
|
||||
const { state } = useCookie<string>('uc-attrs', 'v', {
|
||||
document: fakeDocument,
|
||||
path: '/app',
|
||||
domain: 'example.com',
|
||||
maxAge: 3600,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
});
|
||||
|
||||
await flushWrites();
|
||||
|
||||
state.value = 'next';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(writes).toHaveLength(2);
|
||||
for (const write of writes)
|
||||
expect(write).toContain('Path=/app; Domain=example.com; SameSite=Strict; Max-Age=3600; Secure');
|
||||
});
|
||||
|
||||
it('repeats identity attributes when deleting', async () => {
|
||||
const { writes, document: fakeDocument } = createFakeDocument();
|
||||
|
||||
const { state } = useCookie<string | null>('uc-del-attrs', 'v', {
|
||||
document: fakeDocument,
|
||||
path: '/app',
|
||||
});
|
||||
|
||||
await flushWrites();
|
||||
|
||||
state.value = null;
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(writes.at(-1)).toContain('uc-del-attrs=; Path=/app');
|
||||
expect(writes.at(-1)).toContain('Max-Age=0');
|
||||
});
|
||||
|
||||
it('reports invalid attribute combinations through onError', async () => {
|
||||
const onError = vi.fn();
|
||||
|
||||
const { state } = useCookie<string>('uc-invalid', 'v', {
|
||||
sameSite: 'none',
|
||||
secure: false,
|
||||
writeDefaults: false,
|
||||
onError,
|
||||
});
|
||||
|
||||
state.value = 'next';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(rawCookie('uc-invalid')).toBeNull();
|
||||
});
|
||||
|
||||
// --- Multi-instance sync (BroadcastChannel ping + local re-read) ---
|
||||
|
||||
it('syncs two instances through BroadcastChannel', async () => {
|
||||
const writer = useCookie<string>('uc-sync', 'initial');
|
||||
const reader = useCookie<string>('uc-sync', 'initial');
|
||||
|
||||
writer.state.value = 'from-writer';
|
||||
await nextTick();
|
||||
// Channel delivery is a task — give it two macrotask turns
|
||||
await flushWrites();
|
||||
await flushWrites();
|
||||
await nextTick();
|
||||
|
||||
expect(reader.state.value).toBe('from-writer');
|
||||
});
|
||||
|
||||
it('syncs two instances through the CustomEvent fallback without BroadcastChannel', async () => {
|
||||
vi.stubGlobal('BroadcastChannel', undefined);
|
||||
|
||||
try {
|
||||
const writer = useCookie<string>('uc-sync-ce', 'initial');
|
||||
const reader = useCookie<string>('uc-sync-ce', 'initial');
|
||||
|
||||
writer.state.value = 'from-writer';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
await nextTick();
|
||||
|
||||
expect(reader.state.value).toBe('from-writer');
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not echo a write back into its own instance', async () => {
|
||||
const { state } = useCookie<string>('uc-echo', 'initial');
|
||||
|
||||
state.value = 'next';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(state.value).toBe('next');
|
||||
expect(rawCookie('uc-echo')).toBe('next');
|
||||
});
|
||||
|
||||
// --- Reactive name ---
|
||||
|
||||
it('re-reads when the reactive name changes', async () => {
|
||||
document.cookie = 'uc-name-a=value-a; Path=/';
|
||||
document.cookie = 'uc-name-b=value-b; Path=/';
|
||||
|
||||
const nameRef = ref('uc-name-a');
|
||||
const { state } = useCookie<string>(nameRef, 'default');
|
||||
|
||||
expect(state.value).toBe('value-a');
|
||||
|
||||
nameRef.value = 'uc-name-b';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(state.value).toBe('value-b');
|
||||
expect(rawCookie('uc-name-a')).toBe('value-a');
|
||||
});
|
||||
|
||||
// --- eventFilter ---
|
||||
|
||||
it('applies the event filter to writes', async () => {
|
||||
let captured: (() => void) | undefined;
|
||||
|
||||
const { state } = useCookie<string>('uc-filter', 'initial', {
|
||||
eventFilter: (invoke) => { captured = invoke; },
|
||||
writeDefaults: false,
|
||||
});
|
||||
|
||||
state.value = 'filtered';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(rawCookie('uc-filter')).toBeNull();
|
||||
expect(captured).toBeDefined();
|
||||
|
||||
captured!();
|
||||
await flushWrites();
|
||||
|
||||
expect(rawCookie('uc-filter')).toBe('filtered');
|
||||
});
|
||||
|
||||
// --- Error handling ---
|
||||
|
||||
it('falls back to defaults and reports through onError when deserialization fails', () => {
|
||||
document.cookie = 'uc-bad=not-json; Path=/';
|
||||
const onError = vi.fn();
|
||||
|
||||
const { state } = useCookie('uc-bad', { ok: true }, { onError });
|
||||
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(state.value).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookie Store API', () => {
|
||||
let cookieStore: FakeCookieStore;
|
||||
let storeWindow: Window;
|
||||
|
||||
beforeEach(() => {
|
||||
({ cookieStore, window: storeWindow } = createCookieStoreWindow());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('reads asynchronously: defaults until ready, stored value after', async () => {
|
||||
cookieStore.jar.set('cs-read', 'stored');
|
||||
|
||||
const result = useCookie('cs-read', 'default', { window: storeWindow });
|
||||
|
||||
expect(result.state.value).toBe('default');
|
||||
expect(result.isReady.value).toBeFalsy();
|
||||
|
||||
const { state, isReady } = await result;
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
});
|
||||
|
||||
it('writes through cookieStore.set with the configured attributes', async () => {
|
||||
const { state } = await useCookie<string>('cs-write', 'initial', {
|
||||
window: storeWindow,
|
||||
path: '/app',
|
||||
sameSite: 'strict',
|
||||
maxAge: 3600,
|
||||
});
|
||||
|
||||
const before = Date.now();
|
||||
|
||||
state.value = 'updated';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(cookieStore.jar.get('cs-write')).toBe('updated');
|
||||
|
||||
const call = cookieStore.setCalls.at(-1)!;
|
||||
|
||||
expect(call.path).toBe('/app');
|
||||
expect(call.sameSite).toBe('strict');
|
||||
expect(call.expires).toBeGreaterThanOrEqual(before + 3600 * 1000 - 1000);
|
||||
expect(call.expires).toBeLessThanOrEqual(Date.now() + 3600 * 1000 + 1000);
|
||||
});
|
||||
|
||||
it('deletes through cookieStore.delete when state is set to null', async () => {
|
||||
cookieStore.jar.set('cs-del', 'exists');
|
||||
|
||||
const { state } = await useCookie<string | null>('cs-del', 'default', {
|
||||
window: storeWindow,
|
||||
path: '/app',
|
||||
});
|
||||
|
||||
state.value = null;
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(cookieStore.jar.has('cs-del')).toBeFalsy();
|
||||
expect(cookieStore.deleteCalls.at(-1)).toMatchObject({ name: 'cs-del', path: '/app' });
|
||||
});
|
||||
|
||||
it('persists defaults through cookieStore when the cookie is missing', async () => {
|
||||
await useCookie('cs-defaults', 'fallback', { window: storeWindow });
|
||||
await flushWrites();
|
||||
|
||||
expect(cookieStore.jar.get('cs-defaults')).toBe('fallback');
|
||||
});
|
||||
|
||||
it('updates state on an external change event', async () => {
|
||||
cookieStore.jar.set('cs-ext', 'initial');
|
||||
|
||||
const { state } = await useCookie('cs-ext', 'default', { window: storeWindow });
|
||||
|
||||
cookieStore.jar.set('cs-ext', 'external');
|
||||
cookieStore.fireChange([{ name: 'cs-ext', value: 'external' }], []);
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('external');
|
||||
});
|
||||
|
||||
it('resets to defaults on an external delete event', async () => {
|
||||
cookieStore.jar.set('cs-ext-del', 'stored');
|
||||
|
||||
const { state } = await useCookie('cs-ext-del', 'default', { window: storeWindow });
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
|
||||
cookieStore.jar.delete('cs-ext-del');
|
||||
cookieStore.fireChange([], [{ name: 'cs-ext-del' }]);
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('default');
|
||||
});
|
||||
|
||||
it('ignores change events for other cookies', async () => {
|
||||
cookieStore.jar.set('cs-other', 'mine');
|
||||
|
||||
const { state } = await useCookie('cs-other', 'default', { window: storeWindow });
|
||||
|
||||
cookieStore.fireChange([{ name: 'unrelated', value: 'x' }], []);
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('mine');
|
||||
});
|
||||
|
||||
it('does not bounce its own change-event echo back into the state', async () => {
|
||||
const { state } = await useCookie<string>('cs-echo', 'initial', { window: storeWindow, writeDefaults: false });
|
||||
|
||||
state.value = 'a';
|
||||
await nextTick();
|
||||
state.value = 'b';
|
||||
await nextTick();
|
||||
|
||||
// Let queued writes and their macrotask change events all land.
|
||||
await flushWrites();
|
||||
await flushWrites();
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(state.value).toBe('b');
|
||||
expect(cookieStore.jar.get('cs-echo')).toBe('b');
|
||||
});
|
||||
|
||||
it('converges two instances writing conflicting values in the same tick', async () => {
|
||||
cookieStore.jar.set('cs-conflict', 'initial');
|
||||
|
||||
const a = await useCookie<string>('cs-conflict', 'initial', { window: storeWindow });
|
||||
const b = await useCookie<string>('cs-conflict', 'initial', { window: storeWindow });
|
||||
|
||||
a.state.value = 'from-a';
|
||||
b.state.value = 'from-b';
|
||||
await nextTick();
|
||||
|
||||
// Let queued writes, macrotask change events, and reconciling re-reads settle
|
||||
for (let round = 0; round < 8; round++)
|
||||
await flushWrites();
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
const final = cookieStore.jar.get('cs-conflict');
|
||||
|
||||
expect(a.state.value).toBe(final);
|
||||
expect(b.state.value).toBe(final);
|
||||
});
|
||||
|
||||
it('stops writing once the owning scope is disposed, even after async init', async () => {
|
||||
const scope = effectScope();
|
||||
|
||||
const result = scope.run(() => useCookie<string>('cs-scope', 'init', { window: storeWindow }))!;
|
||||
const { state } = await result;
|
||||
await flushWrites();
|
||||
|
||||
const writesBefore = cookieStore.setCalls.length;
|
||||
|
||||
scope.stop();
|
||||
|
||||
state.value = 'after-stop';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(cookieStore.setCalls).toHaveLength(writesBefore);
|
||||
});
|
||||
|
||||
it('forces the document.cookie path when secure is explicitly false', async () => {
|
||||
const { state } = await useCookie<string>('cs-insecure', 'initial', {
|
||||
window: storeWindow,
|
||||
secure: false,
|
||||
writeDefaults: false,
|
||||
});
|
||||
|
||||
state.value = 'plain';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(cookieStore.setCalls).toHaveLength(0);
|
||||
expect(rawCookie('cs-insecure')).toBe('plain');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom store', () => {
|
||||
/**
|
||||
* In-memory CookieStorageLike — the kind of adapter a framework (e.g.
|
||||
* Nuxt) would provide to bridge a server request context.
|
||||
*/
|
||||
function createMemoryCookieStore() {
|
||||
const jar = new Map<string, string>();
|
||||
const listeners = new Map<string, Set<(value: string | null) => void>>();
|
||||
|
||||
function notify(name: string, value: string | null) {
|
||||
listeners.get(name)?.forEach(callback => callback(value));
|
||||
}
|
||||
|
||||
const store: CookieStorageLike = {
|
||||
getItem: name => jar.get(name) ?? null,
|
||||
setItem: (name, value) => {
|
||||
jar.set(name, value);
|
||||
notify(name, value);
|
||||
},
|
||||
removeItem: (name) => {
|
||||
jar.delete(name);
|
||||
notify(name, null);
|
||||
},
|
||||
onChange: (name, callback) => {
|
||||
if (!listeners.has(name))
|
||||
listeners.set(name, new Set());
|
||||
|
||||
listeners.get(name)!.add(callback);
|
||||
|
||||
return () => listeners.get(name)!.delete(callback);
|
||||
},
|
||||
};
|
||||
|
||||
return { jar, notify, store };
|
||||
}
|
||||
|
||||
const ssr = { window: null as unknown as Window, document: null as unknown as Document };
|
||||
|
||||
it('works without window and document when a store is provided (server)', async () => {
|
||||
const { jar, store } = createMemoryCookieStore();
|
||||
jar.set('srv', 'from-request');
|
||||
|
||||
const { state, isReady } = useCookie<string>('srv', 'default', { ...ssr, store });
|
||||
|
||||
// A synchronous backend initializes synchronously
|
||||
expect(isReady.value).toBeTruthy();
|
||||
expect(state.value).toBe('from-request');
|
||||
|
||||
state.value = 'updated';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(jar.get('srv')).toBe('updated');
|
||||
});
|
||||
|
||||
it('prefers the provided store over the environment', async () => {
|
||||
const { jar, store } = createMemoryCookieStore();
|
||||
document.cookie = 'custom-pref=from-document; Path=/';
|
||||
jar.set('custom-pref', 'from-store');
|
||||
|
||||
const { state } = await useCookie('custom-pref', 'default', { store });
|
||||
|
||||
expect(state.value).toBe('from-store');
|
||||
});
|
||||
|
||||
it('applies external changes from the store subscription without echoing them back', async () => {
|
||||
const { jar, store, notify } = createMemoryCookieStore();
|
||||
const setItem = vi.spyOn(store, 'setItem');
|
||||
|
||||
const { state } = useCookie('sub', 'initial', { ...ssr, store });
|
||||
await flushWrites();
|
||||
|
||||
jar.set('sub', 'external');
|
||||
notify('sub', 'external');
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(state.value).toBe('external');
|
||||
// Only the writeDefaults write — the external change must not bounce back
|
||||
expect(setItem).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converges two instances writing conflicting values in the same tick', async () => {
|
||||
const { jar, store } = createMemoryCookieStore();
|
||||
jar.set('conflict', 'initial');
|
||||
|
||||
const a = useCookie<string>('conflict', 'initial', { ...ssr, store });
|
||||
const b = useCookie<string>('conflict', 'initial', { ...ssr, store });
|
||||
|
||||
a.state.value = 'from-a';
|
||||
b.state.value = 'from-b';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
await flushWrites();
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
const final = jar.get('conflict');
|
||||
|
||||
expect(a.state.value).toBe(final);
|
||||
expect(b.state.value).toBe(final);
|
||||
});
|
||||
|
||||
it('finishes an in-flight write against the old name after a same-tick name switch', async () => {
|
||||
const { jar, store } = createMemoryCookieStore();
|
||||
jar.set('switch-a', 'va0');
|
||||
jar.set('switch-b', 'vb0');
|
||||
|
||||
const nameRef = ref('switch-a');
|
||||
const { state } = useCookie<string>(nameRef, 'default', { ...ssr, store });
|
||||
|
||||
state.value = 'new-a';
|
||||
nameRef.value = 'switch-b';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
// The write lands on the cookie it was meant for, never on the new one
|
||||
expect(jar.get('switch-a')).toBe('new-a');
|
||||
expect(jar.get('switch-b')).toBe('vb0');
|
||||
expect(state.value).toBe('vb0');
|
||||
});
|
||||
|
||||
it('resubscribes when the reactive name changes', async () => {
|
||||
const { jar, store, notify } = createMemoryCookieStore();
|
||||
jar.set('name-a', 'value-a');
|
||||
jar.set('name-b', 'value-b');
|
||||
|
||||
const nameRef = ref('name-a');
|
||||
const { state } = useCookie<string>(nameRef, 'default', { ...ssr, store });
|
||||
|
||||
expect(state.value).toBe('value-a');
|
||||
|
||||
nameRef.value = 'name-b';
|
||||
await nextTick();
|
||||
await flushWrites();
|
||||
|
||||
expect(state.value).toBe('value-b');
|
||||
|
||||
// The old subscription is gone, the new one is live
|
||||
jar.set('name-a', 'stale');
|
||||
notify('name-a', 'stale');
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('value-b');
|
||||
|
||||
jar.set('name-b', 'fresh');
|
||||
notify('name-b', 'fresh');
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('fresh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ssr', () => {
|
||||
it('returns an immediately-ready in-memory ref without window and document', async () => {
|
||||
// Explicit `undefined` would fall back to the defaults via destructuring,
|
||||
// so simulate the server with nulls.
|
||||
const ssr = { window: null as unknown as Window, document: null as unknown as Document };
|
||||
|
||||
const { state, isReady } = useCookie<string>('ssr-key', 'default', ssr);
|
||||
|
||||
expect(state.value).toBe('default');
|
||||
expect(isReady.value).toBeTruthy();
|
||||
|
||||
state.value = 'changed';
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('changed');
|
||||
|
||||
const awaited = await useCookie('ssr-key', 'default', ssr);
|
||||
|
||||
expect(awaited.state.value).toBe('default');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,874 @@
|
||||
import { computed, nextTick, ref, shallowRef, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
|
||||
import { SyncMutex, isFunction } from '@robonen/stdlib';
|
||||
import {
|
||||
decodeCookieValue,
|
||||
encodeCookieName,
|
||||
encodeCookieValue,
|
||||
getCookieValue,
|
||||
serializeCookie,
|
||||
} from '@robonen/platform/browsers';
|
||||
import type { CookieAttributes } from '@robonen/platform/browsers';
|
||||
import type { ConfigurableDocument, ConfigurableFlush, ConfigurableWindow } from '@/types';
|
||||
import { defaultDocument, defaultWindow } from '@/types';
|
||||
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
import { guessSerializer, shallowMerge } from '../useStorage';
|
||||
import type { StorageSerializer } from '../useStorage';
|
||||
|
||||
// CookieAttributes is part of this module's public contract (options extend
|
||||
// it, CookieStorageLike methods receive it) — re-export it so adapter authors
|
||||
// don't need a direct dependency on @robonen/platform.
|
||||
export type { CookieAttributes } from '@robonen/platform/browsers';
|
||||
|
||||
/**
|
||||
* A cookie backend the composable depends on — never on a concrete API.
|
||||
* Names are raw (unencoded); values are already encoded cookie values.
|
||||
* Methods may be sync or async, so an implementation can sit on the Cookie
|
||||
* Store API, `document.cookie`, or a server request context (e.g. a Nuxt
|
||||
* adapter reading request cookies and appending `Set-Cookie` headers).
|
||||
*/
|
||||
export interface CookieStorageLike {
|
||||
/**
|
||||
* Read the raw (encoded) cookie value, `null` when absent.
|
||||
*/
|
||||
getItem: (name: string) => string | null | Promise<string | null>;
|
||||
/**
|
||||
* Write the raw value. Attributes carry the cookie's scope and lifetime.
|
||||
*/
|
||||
setItem: (name: string, value: string, attributes: CookieAttributes) => void | Promise<void>;
|
||||
/**
|
||||
* Delete the cookie. Attributes carry the identity (`path`/`domain`) the
|
||||
* deletion must repeat to match.
|
||||
*/
|
||||
removeItem: (name: string, attributes: CookieAttributes) => void | Promise<void>;
|
||||
/**
|
||||
* Optional: observe changes of the given cookie (other tabs, server
|
||||
* `Set-Cookie` responses, other instances). The callback receives the new
|
||||
* raw value (`null` = deleted). Returns an unsubscribe function.
|
||||
*/
|
||||
onChange?: (name: string, callback: (newValue: string | null) => void) => () => void;
|
||||
/**
|
||||
* Whether `onChange` also reports this context's own writes (e.g. the
|
||||
* Cookie Store API `change` event does, a BroadcastChannel does not). The
|
||||
* composable uses this to know when an own write will come back as a
|
||||
* notification that must be swallowed instead of re-applied.
|
||||
* @default true
|
||||
*/
|
||||
readonly echoesOwnWrites?: boolean;
|
||||
}
|
||||
|
||||
export const customCookieEventName = 'vuetools-cookie';
|
||||
|
||||
/**
|
||||
* Detail of the {@link customCookieEventName} CustomEvent the
|
||||
* `document.cookie` adapter dispatches on `window` after every write, for
|
||||
* same-tab sync in environments without the Cookie Store API and without
|
||||
* BroadcastChannel. The same string also names the adapter's BroadcastChannel.
|
||||
*/
|
||||
export interface CookieChangeLike {
|
||||
/**
|
||||
* The raw (unencoded) cookie name.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The encoded cookie value, `null` for a deletion.
|
||||
*/
|
||||
newValue: string | null;
|
||||
}
|
||||
|
||||
function cookieStoreExpires(attributes: CookieAttributes): number | null {
|
||||
if (attributes.maxAge !== undefined)
|
||||
return Date.now() + attributes.maxAge * 1000;
|
||||
if (attributes.expires !== undefined)
|
||||
return attributes.expires instanceof Date ? attributes.expires.getTime() : attributes.expires;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name createCookieStoreAdapter
|
||||
* @category Storage
|
||||
* @description {@link CookieStorageLike} adapter over the
|
||||
* [Cookie Store API](https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API):
|
||||
* async reads/writes plus a `change`-event subscription that observes other
|
||||
* tabs, server `Set-Cookie` responses, and expiry. The API can only write
|
||||
* `Secure` cookies, so `secure: false` is rejected — use
|
||||
* {@link createDocumentCookieAdapter} for that.
|
||||
*
|
||||
* @param {CookieStore} cookieStore The `window.cookieStore` instance to wrap
|
||||
* @returns {CookieStorageLike} The adapter
|
||||
*
|
||||
* @example
|
||||
* const store = createCookieStoreAdapter(window.cookieStore);
|
||||
*
|
||||
* @since 0.0.14
|
||||
*/
|
||||
export function createCookieStoreAdapter(cookieStore: CookieStore): CookieStorageLike {
|
||||
return {
|
||||
// The change event fires for this document's own writes too
|
||||
echoesOwnWrites: true,
|
||||
getItem(name) {
|
||||
return cookieStore.get(encodeCookieName(name)).then(item => item?.value ?? null);
|
||||
},
|
||||
async setItem(name, value, attributes) {
|
||||
if (attributes.secure === false)
|
||||
throw new TypeError('[useCookie] the Cookie Store API can only write Secure cookies — use the document.cookie adapter for secure: false');
|
||||
|
||||
await cookieStore.set({
|
||||
name: encodeCookieName(name),
|
||||
value,
|
||||
expires: cookieStoreExpires(attributes),
|
||||
domain: attributes.domain ?? null,
|
||||
path: attributes.path ?? '/',
|
||||
sameSite: attributes.sameSite ?? 'lax',
|
||||
partitioned: attributes.partitioned ?? false,
|
||||
});
|
||||
},
|
||||
async removeItem(name, attributes) {
|
||||
await cookieStore.delete({
|
||||
name: encodeCookieName(name),
|
||||
domain: attributes.domain ?? null,
|
||||
path: attributes.path ?? '/',
|
||||
partitioned: attributes.partitioned ?? false,
|
||||
});
|
||||
},
|
||||
onChange(name, callback) {
|
||||
const encodedName = encodeCookieName(name);
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const { changed, deleted } = event as CookieChangeEvent;
|
||||
|
||||
for (const item of deleted) {
|
||||
if (item.name === encodedName)
|
||||
return callback(null);
|
||||
}
|
||||
|
||||
for (const item of changed) {
|
||||
if (item.name === encodedName)
|
||||
return callback(item.value ?? '');
|
||||
}
|
||||
};
|
||||
|
||||
cookieStore.addEventListener('change', handler);
|
||||
|
||||
return () => cookieStore.removeEventListener('change', handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface DocumentCookieAdapterOptions extends ConfigurableWindow {}
|
||||
|
||||
/**
|
||||
* @name createDocumentCookieAdapter
|
||||
* @category Storage
|
||||
* @description {@link CookieStorageLike} adapter over `document.cookie`:
|
||||
* synchronous reads/writes that work in every browser and can express
|
||||
* non-`Secure` cookies. Changes are observed through the Cookie Store API
|
||||
* `change` event when the browser has one (covering other tabs, server
|
||||
* responses, and expiry even though writes stay on `document.cookie`);
|
||||
* otherwise every write pings a BroadcastChannel by cookie name and receivers
|
||||
* re-read their own `document.cookie` (same-tab and cross-tab, ordering-proof
|
||||
* since no value travels), with a same-tab CustomEvent as the last resort.
|
||||
* Without the Cookie Store API, changes made outside the adapter (server
|
||||
* `Set-Cookie`, other libraries) are not observed.
|
||||
*
|
||||
* @param {Document} document The document whose cookies to read/write
|
||||
* @param {DocumentCookieAdapterOptions} [options={}] Options (`window`)
|
||||
* @returns {CookieStorageLike} The adapter
|
||||
*
|
||||
* @example
|
||||
* const store = createDocumentCookieAdapter(document);
|
||||
*
|
||||
* @since 0.0.14
|
||||
*/
|
||||
export function createDocumentCookieAdapter(document: Document, options: DocumentCookieAdapterOptions = {}): CookieStorageLike {
|
||||
const { window = defaultWindow } = options;
|
||||
const cookieStore = window?.cookieStore;
|
||||
const supportsBroadcast = typeof BroadcastChannel !== 'undefined';
|
||||
|
||||
function read(name: string): string | null {
|
||||
return getCookieValue(document.cookie, name, value => value);
|
||||
}
|
||||
|
||||
// One lazy channel per adapter, shared by posts and subscriptions — the
|
||||
// posting channel object never receives its own messages, which is exactly
|
||||
// the echo-free behavior the composable expects (echoesOwnWrites: false).
|
||||
let channel: BroadcastChannel | undefined;
|
||||
let subscribers = 0;
|
||||
|
||||
function broadcastChannel(): BroadcastChannel {
|
||||
if (!channel) {
|
||||
channel = new BroadcastChannel(customCookieEventName);
|
||||
// Node's BroadcastChannel holds the event loop open — release it (no-op in browsers)
|
||||
(channel as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
function notify(name: string, newValue: string | null) {
|
||||
// The Cookie Store API observes document.cookie writes by itself.
|
||||
if (cookieStore)
|
||||
return;
|
||||
|
||||
if (supportsBroadcast) {
|
||||
// Ping by name only — receivers re-read their own document.cookie, so a
|
||||
// late or reordered message can never apply a stale value.
|
||||
broadcastChannel().postMessage(name);
|
||||
return;
|
||||
}
|
||||
|
||||
window?.dispatchEvent(new CustomEvent<CookieChangeLike>(customCookieEventName, {
|
||||
detail: { name, newValue },
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
// Own document.cookie writes echo back only through paths that observe
|
||||
// the cookie jar itself (Cookie Store API) or the same window (CustomEvent);
|
||||
// BroadcastChannel posts never return to the posting adapter.
|
||||
echoesOwnWrites: !!cookieStore || (!supportsBroadcast && !!window),
|
||||
getItem: read,
|
||||
setItem(name, value, attributes) {
|
||||
document.cookie = serializeCookie(encodeCookieName(name), value, attributes);
|
||||
notify(name, value);
|
||||
},
|
||||
removeItem(name, attributes) {
|
||||
// Deletion must repeat the identity attributes (path/domain) or it
|
||||
// silently misses the cookie it is meant to remove.
|
||||
document.cookie = serializeCookie(encodeCookieName(name), '', { ...attributes, maxAge: 0, expires: 0 });
|
||||
notify(name, null);
|
||||
},
|
||||
onChange(name, callback) {
|
||||
let teardown: () => void;
|
||||
|
||||
// The Cookie Store API observes document.cookie writes too — prefer its
|
||||
// change event, which also covers other tabs and server responses.
|
||||
if (cookieStore) {
|
||||
const encodedName = encodeCookieName(name);
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const { changed, deleted } = event as CookieChangeEvent;
|
||||
|
||||
for (const item of deleted) {
|
||||
if (item.name === encodedName)
|
||||
return callback(null);
|
||||
}
|
||||
|
||||
for (const item of changed) {
|
||||
if (item.name === encodedName)
|
||||
return callback(item.value ?? '');
|
||||
}
|
||||
};
|
||||
|
||||
cookieStore.addEventListener('change', handler);
|
||||
teardown = () => cookieStore.removeEventListener('change', handler);
|
||||
}
|
||||
else if (supportsBroadcast) {
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (event.data === name)
|
||||
callback(read(name));
|
||||
};
|
||||
|
||||
const bc = broadcastChannel();
|
||||
subscribers++;
|
||||
bc.addEventListener('message', handler);
|
||||
|
||||
teardown = () => {
|
||||
bc.removeEventListener('message', handler);
|
||||
|
||||
// Close the channel with the last subscriber; posts reopen it lazily
|
||||
if (--subscribers === 0 && channel) {
|
||||
channel.close();
|
||||
channel = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
else {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<CookieChangeLike>).detail;
|
||||
|
||||
if (detail.name === name)
|
||||
callback(detail.newValue);
|
||||
};
|
||||
|
||||
window?.addEventListener(customCookieEventName, handler);
|
||||
teardown = () => window?.removeEventListener(customCookieEventName, handler);
|
||||
}
|
||||
|
||||
return () => teardown();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseCookieOptions<T, Shallow extends boolean = true>
|
||||
extends CookieAttributes, ConfigurableWindow, ConfigurableDocument, ConfigurableFlush, ConfigurableEventFilter {
|
||||
/**
|
||||
* The cookie backend. Defaults to {@link createCookieStoreAdapter} when the
|
||||
* browser has the Cookie Store API (unless `secure: false`),
|
||||
* {@link createDocumentCookieAdapter} otherwise. Pass a custom
|
||||
* implementation to integrate a framework's cookie context (e.g. Nuxt) so
|
||||
* the composable also works during SSR.
|
||||
*/
|
||||
store?: CookieStorageLike;
|
||||
/**
|
||||
* Use shallowRef instead of ref for the internal state
|
||||
* @default true
|
||||
*/
|
||||
shallow?: Shallow;
|
||||
/**
|
||||
* Watch for deep changes
|
||||
* @default true
|
||||
*/
|
||||
deep?: boolean;
|
||||
/**
|
||||
* Listen to cookie changes through the store's `onChange` subscription
|
||||
* @default true
|
||||
*/
|
||||
listenToStorageChanges?: boolean;
|
||||
/**
|
||||
* Write the default value to the cookie when it does not exist
|
||||
* @default true
|
||||
*/
|
||||
writeDefaults?: boolean;
|
||||
/**
|
||||
* Merge the default value with the stored value
|
||||
* @default false
|
||||
*/
|
||||
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
|
||||
/**
|
||||
* Custom serializer for reading/writing the cookie value
|
||||
*/
|
||||
serializer?: StorageSerializer<T>;
|
||||
/**
|
||||
* Encodes the serialized string into an RFC 6265-safe cookie value
|
||||
* @default encodeCookieValue
|
||||
*/
|
||||
encode?: (value: string) => string;
|
||||
/**
|
||||
* Decodes a raw cookie value before deserialization
|
||||
* @default decodeCookieValue
|
||||
*/
|
||||
decode?: (value: string) => string;
|
||||
/**
|
||||
* Called once when the initial value has been loaded from the cookie
|
||||
*/
|
||||
onReady?: (value: T) => void;
|
||||
/**
|
||||
* Error handler for read/write failures
|
||||
*/
|
||||
onError?: (error: unknown) => void;
|
||||
/**
|
||||
* Wait for the component to be mounted before reading the cookie
|
||||
*
|
||||
* Useful for SSR hydration to prevent mismatch
|
||||
* @default false
|
||||
*/
|
||||
initOnMounted?: boolean;
|
||||
}
|
||||
|
||||
export interface UseCookieReturnBase<T, Shallow extends boolean> {
|
||||
state: Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
|
||||
isReady: Ref<boolean>;
|
||||
}
|
||||
|
||||
export type UseCookieReturn<T, Shallow extends boolean>
|
||||
= & UseCookieReturnBase<T, Shallow>
|
||||
& PromiseLike<UseCookieReturnBase<T, Shallow>>;
|
||||
|
||||
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
||||
return !!value && isFunction((value as { then?: unknown }).then);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useCookie
|
||||
* @category Storage
|
||||
* @description Reactive cookie binding — creates a ref synced with a cookie
|
||||
* through a pluggable {@link CookieStorageLike} backend. By default that is
|
||||
* the [Cookie Store API](https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API)
|
||||
* when the browser supports it (async, with `change`-event sync across tabs
|
||||
* and server `Set-Cookie` responses) and `document.cookie` otherwise
|
||||
* (synchronous, BroadcastChannel same-tab/cross-tab sync); pass a custom
|
||||
* `store` to run on top of a framework's cookie context (e.g. Nuxt)
|
||||
* including during SSR. Setting the state to `null` deletes the cookie. Cookie
|
||||
* attributes (`path`, `domain`, `maxAge`/`expires`, `secure`, `sameSite`,
|
||||
* `partitioned`) apply to every write; `secure` defaults to the page's
|
||||
* secure-context status, and an explicit `secure: false` selects the
|
||||
* `document.cookie` adapter since the Cookie Store API can only write
|
||||
* `Secure` cookies.
|
||||
*
|
||||
* @param {MaybeRefOrGetter<string>} name The cookie name (can be reactive)
|
||||
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||
* @param {UseCookieOptions<T>} [options={}] Options
|
||||
* @returns {UseCookieReturn<T, Shallow>} An object with state ref and isReady flag, also awaitable
|
||||
*
|
||||
* @example
|
||||
* const { state: theme } = useCookie('theme', 'system');
|
||||
*
|
||||
* @example
|
||||
* const { state: session } = await useCookie('session', '', { maxAge: 3600, sameSite: 'strict' });
|
||||
*
|
||||
* @example
|
||||
* // Nuxt (h3) integration: bridge the SSR request so the same call works on
|
||||
* // the server (reads the request's Cookie header, writes Set-Cookie response
|
||||
* // headers) and falls back to the built-in browser adapters on the client.
|
||||
* // ~/composables/createNuxtCookieAdapter.ts
|
||||
* import { deleteCookie, getRequestHeader, setCookie } from 'h3';
|
||||
* import { getCookieValue } from '@robonen/platform/browsers';
|
||||
* import type { CookieStorageLike } from '@robonen/vue';
|
||||
*
|
||||
* export function createNuxtCookieAdapter(): CookieStorageLike | undefined {
|
||||
* if (import.meta.client)
|
||||
* return undefined; // browser: the default adapters take over
|
||||
*
|
||||
* const event = useRequestEvent()!;
|
||||
* // Set-Cookie does not update the incoming Cookie header, so reads must
|
||||
* // overlay this request's own writes (same trick Nuxt's useCookie uses)
|
||||
* const written = new Map<string, string | null>();
|
||||
*
|
||||
* return {
|
||||
* getItem(name) {
|
||||
* if (written.has(name))
|
||||
* return written.get(name)!;
|
||||
*
|
||||
* // identity decode — the composable owns decoding of raw values
|
||||
* return getCookieValue(getRequestHeader(event, 'cookie') ?? '', name, raw => raw);
|
||||
* },
|
||||
* setItem(name, value, attributes) {
|
||||
* written.set(name, value);
|
||||
* setCookie(event, name, value, {
|
||||
* path: attributes.path,
|
||||
* domain: attributes.domain,
|
||||
* maxAge: attributes.maxAge,
|
||||
* expires: typeof attributes.expires === 'number' ? new Date(attributes.expires) : attributes.expires,
|
||||
* secure: attributes.secure,
|
||||
* sameSite: attributes.sameSite,
|
||||
* partitioned: attributes.partitioned,
|
||||
* encode: raw => raw, // value arrives already encoded
|
||||
* });
|
||||
* },
|
||||
* removeItem(name, attributes) {
|
||||
* written.set(name, null);
|
||||
* // deletion must repeat the identity attributes to match the cookie
|
||||
* deleteCookie(event, name, { path: attributes.path, domain: attributes.domain });
|
||||
* },
|
||||
* // no onChange: a server request has no cookie change events
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* // anywhere in the app — works during SSR and in the browser
|
||||
* const { state: locale } = useCookie('locale', 'en', { store: createNuxtCookieAdapter() });
|
||||
*
|
||||
* @since 0.0.14
|
||||
*/
|
||||
export function useCookie<T extends string, Shallow extends boolean = true>(name: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseCookieOptions<T, Shallow>): UseCookieReturn<T, Shallow>;
|
||||
export function useCookie<T extends number, Shallow extends boolean = true>(name: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseCookieOptions<T, Shallow>): UseCookieReturn<T, Shallow>;
|
||||
export function useCookie<T extends boolean, Shallow extends boolean = true>(name: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseCookieOptions<T, Shallow>): UseCookieReturn<T, Shallow>;
|
||||
export function useCookie<T, Shallow extends boolean = true>(name: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseCookieOptions<T, Shallow>): UseCookieReturn<T, Shallow>;
|
||||
export function useCookie<T = unknown, Shallow extends boolean = true>(name: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, options?: UseCookieOptions<T, Shallow>): UseCookieReturn<T, Shallow>;
|
||||
export function useCookie<T, Shallow extends boolean = true>(
|
||||
name: MaybeRefOrGetter<string>,
|
||||
initialValue: MaybeRefOrGetter<T>,
|
||||
options: UseCookieOptions<T, Shallow> = {},
|
||||
): UseCookieReturn<T, Shallow> {
|
||||
const {
|
||||
path = '/',
|
||||
domain,
|
||||
maxAge,
|
||||
expires,
|
||||
sameSite = 'lax',
|
||||
partitioned = false,
|
||||
shallow = true,
|
||||
deep = true,
|
||||
flush = 'pre',
|
||||
listenToStorageChanges = true,
|
||||
writeDefaults = true,
|
||||
mergeDefaults = false,
|
||||
encode = encodeCookieValue,
|
||||
decode = decodeCookieValue,
|
||||
eventFilter,
|
||||
initOnMounted = false,
|
||||
onReady,
|
||||
onError = console.error, // eslint-disable-line no-console
|
||||
window = defaultWindow,
|
||||
document = defaultDocument,
|
||||
} = options;
|
||||
|
||||
// The Cookie Store API can only write `Secure` cookies, so when it backs the
|
||||
// default store the default must be true; an explicit `secure: false`
|
||||
// selects the document.cookie adapter instead.
|
||||
const secure = options.secure ?? (window?.cookieStore ? true : window?.isSecureContext ?? false);
|
||||
|
||||
const store = options.store ?? (
|
||||
window?.cookieStore && secure
|
||||
? createCookieStoreAdapter(window.cookieStore)
|
||||
: document
|
||||
? createDocumentCookieAdapter(document, { window })
|
||||
: undefined
|
||||
);
|
||||
|
||||
const attributes: CookieAttributes = { path, domain, maxAge, expires, secure, sameSite, partitioned };
|
||||
|
||||
const defaults = toValue(initialValue);
|
||||
const serializer = options.serializer ?? guessSerializer(defaults);
|
||||
|
||||
const state = (shallow ? shallowRef : ref)(defaults) as Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
|
||||
const isReady = ref(false);
|
||||
const rawName = computed(() => toValue(name));
|
||||
|
||||
const shell: UseCookieReturnBase<T, Shallow> = {
|
||||
state,
|
||||
isReady,
|
||||
};
|
||||
|
||||
// No backend at all (SSR without a custom store): a plain in-memory ref,
|
||||
// immediately ready.
|
||||
if (!store) {
|
||||
isReady.value = true;
|
||||
|
||||
return {
|
||||
...shell,
|
||||
// eslint-disable-next-line unicorn/no-thenable
|
||||
then(onFulfilled, onRejected) {
|
||||
return Promise.resolve(shell).then(onFulfilled, onRejected);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toRaw(value: T): string | null {
|
||||
return value === undefined || value === null ? null : encode(serializer.write(value));
|
||||
}
|
||||
|
||||
function fromRaw(raw: string | null, merge: boolean): T {
|
||||
if (raw === null)
|
||||
return defaults;
|
||||
|
||||
const value = serializer.read(decode(raw));
|
||||
|
||||
if (merge && mergeDefaults) {
|
||||
return isFunction(mergeDefaults)
|
||||
? mergeDefaults(value, defaults)
|
||||
: shallowMerge(value, defaults);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// The raw value the cookie is known to hold (last read, observed, or
|
||||
// written) — lets writes skip no-ops without a read-before-write roundtrip.
|
||||
let knownRaw: string | null | undefined;
|
||||
|
||||
// Raw values of own in-flight writes — only for stores whose onChange
|
||||
// reports our own writes back (echoesOwnWrites): during rapid queued writes
|
||||
// a stale own echo must not bounce back into the state and clobber a newer
|
||||
// value. Echo-free stores (BroadcastChannel) never need this.
|
||||
const selfEchoes: Array<string | null> = [];
|
||||
const observesChanges = listenToStorageChanges && !!store.onChange;
|
||||
const tracksEchoes = observesChanges && store.echoesOwnWrites !== false;
|
||||
|
||||
// Bumped on every reactive name switch: in-flight writes finish against
|
||||
// their snapshotted name without touching the new name's bookkeeping.
|
||||
let nameEpoch = 0;
|
||||
|
||||
// The name writes target. Updated ONLY by the name watcher — by watcher
|
||||
// flush time the rawName computed already reflects a same-tick name change,
|
||||
// so a write enqueued in that flush would otherwise land on the new cookie.
|
||||
let currentName = rawName.value;
|
||||
|
||||
// Writes still queued or in flight. Foreign change notifications arriving
|
||||
// while > 0 are ordering-ambiguous and are deferred to a reconciling
|
||||
// re-read once the queue drains.
|
||||
let pendingWrites = 0;
|
||||
let needsReconcile = false;
|
||||
|
||||
// FIFO write queue: keeps rapid writes ordered even when the backend
|
||||
// resolves them out of order, and serializes delete-after-set.
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function queueWrite(value: T, onlyIfAbsent = false) {
|
||||
// Snapshot the target: a write enqueued before a name switch must land on
|
||||
// the cookie it was meant for, never on the new name.
|
||||
const epoch = nameEpoch;
|
||||
const target = currentName;
|
||||
|
||||
pendingWrites++;
|
||||
|
||||
writeQueue = writeQueue.then(async () => {
|
||||
// A defaults write re-checks at execution time: another instance (or
|
||||
// tab) may have persisted a value since this was enqueued, and writing
|
||||
// the defaults over it would stomp that newer value.
|
||||
if (onlyIfAbsent) {
|
||||
const existing = await store!.getItem(target);
|
||||
|
||||
if (existing !== undefined && existing !== null) {
|
||||
needsReconcile = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const raw = toRaw(value);
|
||||
|
||||
// The no-op skip and all bookkeeping belong to the current name only
|
||||
const current = () => epoch === nameEpoch;
|
||||
|
||||
if (current() && raw === knownRaw)
|
||||
return;
|
||||
|
||||
// Push before the write: a synchronous backend notifies during it.
|
||||
const trackEcho = tracksEchoes && current();
|
||||
|
||||
if (trackEcho)
|
||||
selfEchoes.push(raw);
|
||||
|
||||
try {
|
||||
if (raw === null)
|
||||
await store!.removeItem(target, attributes);
|
||||
else
|
||||
await store!.setItem(target, raw, attributes);
|
||||
|
||||
if (current())
|
||||
knownRaw = raw;
|
||||
}
|
||||
catch (error) {
|
||||
if (trackEcho) {
|
||||
const index = selfEchoes.indexOf(raw);
|
||||
|
||||
if (index !== -1)
|
||||
selfEchoes.splice(index, 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}).catch(onError).then(() => {
|
||||
pendingWrites--;
|
||||
maybeReconcile();
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve a change deferred by in-flight writes: re-read the source of
|
||||
// truth once instead of trusting possibly-reordered notifications.
|
||||
function maybeReconcile() {
|
||||
if (pendingWrites > 0 || !needsReconcile)
|
||||
return;
|
||||
|
||||
needsReconcile = false;
|
||||
|
||||
const epoch = nameEpoch;
|
||||
const stamp = changeStamp;
|
||||
|
||||
Promise.resolve(store!.getItem(rawName.value)).then((raw) => {
|
||||
// A name switch or a newer external change supersedes this snapshot
|
||||
if (epoch !== nameEpoch || stamp !== changeStamp)
|
||||
return;
|
||||
|
||||
// Any echoes still inbound are indistinguishable from the re-read truth
|
||||
selfEchoes.length = 0;
|
||||
applyExternal(raw);
|
||||
}).catch(onError);
|
||||
}
|
||||
|
||||
// Apply event filter if provided
|
||||
const writeWithFilter: (value: T) => void = eventFilter
|
||||
? (value: T) => (eventFilter as EventFilter)(() => queueWrite(value))
|
||||
: queueWrite;
|
||||
|
||||
// Write-lock prevents the state watcher from writing back when state is
|
||||
// updated programmatically (initial/external reads, name changes). Released
|
||||
// via nextTick so it persists through the pre-flush watcher cycle.
|
||||
const writeLock = new SyncMutex();
|
||||
|
||||
function lockWritesUntilFlush() {
|
||||
writeLock.lock();
|
||||
nextTick(() => writeLock.unlock());
|
||||
}
|
||||
|
||||
// The defaults never change, so their raw form is computed once and lazily —
|
||||
// external deletes compare against it without re-serializing.
|
||||
let defaultsRawCache: string | null | undefined;
|
||||
|
||||
function defaultsRaw(): string | null {
|
||||
if (defaultsRawCache === undefined)
|
||||
defaultsRawCache = toRaw(defaults);
|
||||
|
||||
return defaultsRawCache;
|
||||
}
|
||||
|
||||
// Bumped when an external change lands in the state — an async snapshot
|
||||
// read (init, name switch, reconcile) that started earlier compares stamps
|
||||
// so it never clobbers the newer value.
|
||||
let changeStamp = 0;
|
||||
|
||||
function applyExternal(raw: string | null) {
|
||||
// The known value re-seen (an echo of an observed write, a redundant
|
||||
// notification) — and while an own write is in flight, knownRaw lags the
|
||||
// state on purpose, so this also drops events that would clobber it.
|
||||
if (raw === knownRaw)
|
||||
return;
|
||||
|
||||
knownRaw = raw;
|
||||
|
||||
try {
|
||||
// Compare serialized forms so an external delete while the state already
|
||||
// equals the defaults is a no-op; deserialize only on a real change.
|
||||
const incomingRaw = raw === null ? defaultsRaw() : raw;
|
||||
|
||||
if (incomingRaw === toRaw(state.value as T))
|
||||
return;
|
||||
|
||||
changeStamp++;
|
||||
lockWritesUntilFlush();
|
||||
(state as Ref).value = fromRaw(raw, false);
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
let firstMounted = false;
|
||||
const skipUntilMounted = () => initOnMounted && !firstMounted;
|
||||
|
||||
function handleChange(newValue: string | null) {
|
||||
if (skipUntilMounted())
|
||||
return;
|
||||
|
||||
const echoIndex = selfEchoes.indexOf(newValue);
|
||||
|
||||
if (echoIndex !== -1) {
|
||||
selfEchoes.splice(echoIndex, 1);
|
||||
// A foreign change deferred behind this echo may be ready to resolve now
|
||||
maybeReconcile();
|
||||
return;
|
||||
}
|
||||
|
||||
// A foreign change interleaved with own in-flight writes (queued, or
|
||||
// committed with echoes still inbound) is ordering-ambiguous: applying it
|
||||
// could revert a newer own value. Defer to one reconciling re-read.
|
||||
if (pendingWrites > 0 || selfEchoes.length > 0) {
|
||||
needsReconcile = true;
|
||||
return;
|
||||
}
|
||||
|
||||
applyExternal(newValue);
|
||||
}
|
||||
|
||||
// The change subscription is name-bound, so a reactive name change
|
||||
// resubscribes (see the name watcher in finishInit)
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
function subscribe() {
|
||||
unsubscribe?.();
|
||||
unsubscribe = observesChanges ? store!.onChange!(rawName.value, handleChange) : undefined;
|
||||
}
|
||||
|
||||
subscribe();
|
||||
|
||||
let stopWatch: (() => void) | undefined;
|
||||
let stopNameWatch: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
|
||||
tryOnScopeDispose(() => {
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
stopWatch?.();
|
||||
stopNameWatch?.();
|
||||
});
|
||||
|
||||
function applyRead(raw: string | null, stamp: number) {
|
||||
// An external change applied while this snapshot read was in flight is
|
||||
// fresher than the snapshot — keep it.
|
||||
if (stamp !== changeStamp)
|
||||
return;
|
||||
|
||||
knownRaw = raw;
|
||||
|
||||
if (raw === null && writeDefaults && defaults !== undefined && defaults !== null)
|
||||
queueWrite(defaults as T, true);
|
||||
|
||||
lockWritesUntilFlush();
|
||||
(state as Ref).value = fromRaw(raw, true);
|
||||
}
|
||||
|
||||
function finishInit() {
|
||||
// The scope died before the async init resolved — leave no watchers behind
|
||||
if (disposed)
|
||||
return;
|
||||
|
||||
isReady.value = true;
|
||||
onReady?.(state.value as T);
|
||||
|
||||
// Set up watchers AFTER initial state is set — avoids write-back on init
|
||||
stopWatch = watch(state, (newValue) => {
|
||||
if (writeLock.isLocked)
|
||||
return;
|
||||
|
||||
writeWithFilter(newValue as T);
|
||||
}, { flush, deep });
|
||||
|
||||
// Watch for reactive name changes
|
||||
stopNameWatch = watch(rawName, () => {
|
||||
nameEpoch++;
|
||||
currentName = rawName.value;
|
||||
selfEchoes.length = 0;
|
||||
needsReconcile = false;
|
||||
subscribe();
|
||||
|
||||
const stamp = changeStamp;
|
||||
|
||||
Promise.resolve(store!.getItem(rawName.value))
|
||||
.then(raw => applyRead(raw, stamp))
|
||||
.catch(onError);
|
||||
}, { flush });
|
||||
}
|
||||
|
||||
function performInit(): Promise<UseCookieReturnBase<T, Shallow>> | UseCookieReturnBase<T, Shallow> {
|
||||
const stamp = changeStamp;
|
||||
const raw = store!.getItem(rawName.value);
|
||||
|
||||
// A synchronous backend (document.cookie, a server request context)
|
||||
// initializes synchronously — state is correct right after the call.
|
||||
if (isThenable(raw)) {
|
||||
return Promise.resolve(raw)
|
||||
.then(resolved => applyRead(resolved, stamp))
|
||||
.catch(onError)
|
||||
.then(() => {
|
||||
finishInit();
|
||||
return shell;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
applyRead(raw, stamp);
|
||||
}
|
||||
catch (error) {
|
||||
onError(error);
|
||||
}
|
||||
|
||||
finishInit();
|
||||
|
||||
return shell;
|
||||
}
|
||||
|
||||
let readyPromise: Promise<UseCookieReturnBase<T, Shallow>>;
|
||||
|
||||
if (initOnMounted) {
|
||||
readyPromise = new Promise<UseCookieReturnBase<T, Shallow>>((resolve) => {
|
||||
tryOnMounted(() => {
|
||||
firstMounted = true;
|
||||
resolve(performInit());
|
||||
});
|
||||
});
|
||||
}
|
||||
else {
|
||||
readyPromise = Promise.resolve(performInit());
|
||||
}
|
||||
|
||||
return {
|
||||
...shell,
|
||||
// eslint-disable-next-line unicorn/no-thenable
|
||||
then(onFulfilled, onRejected) {
|
||||
return readyPromise.then(onFulfilled, onRejected);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -359,6 +359,175 @@ describe(useStorageAsync, () => {
|
||||
expect(state.value).toBe('value-b');
|
||||
});
|
||||
|
||||
// --- Same-tab cross-instance sync (custom backends) ---
|
||||
|
||||
it('syncs two instances sharing the same custom async backend', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
|
||||
const writer = await useStorageAsync<string>('shared', 'initial', storage);
|
||||
const reader = await useStorageAsync<string>('shared', 'initial', storage);
|
||||
|
||||
writer.state.value = 'from-writer';
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
|
||||
expect(reader.state.value).toBe('from-writer');
|
||||
});
|
||||
|
||||
it('does not echo a received event back into storage', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
|
||||
const writer = await useStorageAsync<string>('echo', 'initial', storage);
|
||||
await useStorageAsync<string>('echo', 'initial', storage);
|
||||
|
||||
const setItem = vi.spyOn(storage, 'setItem');
|
||||
|
||||
writer.state.value = 'next';
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// Only the writer persists; the reader applies the event without writing back
|
||||
expect(setItem).toHaveBeenCalledTimes(1);
|
||||
expect(storage.store.get('echo')).toBe('next');
|
||||
});
|
||||
|
||||
it('resets to defaults on a clear event (key: null)', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('clearable', 'stored');
|
||||
|
||||
const { state } = await useStorageAsync<string>('clearable', 'default', storage);
|
||||
|
||||
expect(state.value).toBe('stored');
|
||||
|
||||
globalThis.dispatchEvent(new CustomEvent('vuetools-storage', {
|
||||
detail: { key: null, oldValue: null, newValue: null, storageArea: storage },
|
||||
}));
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('default');
|
||||
});
|
||||
|
||||
// --- No-op writes ---
|
||||
|
||||
it('skips the write when storage already holds the serialized value', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('noop', JSON.stringify({ a: 1 }));
|
||||
|
||||
const { state } = await useStorageAsync('noop', { a: 0 }, storage);
|
||||
const setItem = vi.spyOn(storage, 'setItem');
|
||||
|
||||
state.value = { a: 1 };
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- Key switch must not copy the previous key's value ---
|
||||
|
||||
it('does not write the old value to the new key on reactive key change', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('key-a', 'value-a');
|
||||
storage.store.set('key-b', 'value-b');
|
||||
|
||||
const keyRef = ref('key-a');
|
||||
const { state } = await useStorageAsync<string>(keyRef, 'default', storage);
|
||||
const setItem = vi.spyOn(storage, 'setItem');
|
||||
|
||||
keyRef.value = 'key-b';
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
expect(state.value).toBe('value-b');
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
expect(storage.store.get('key-a')).toBe('value-a');
|
||||
expect(storage.store.get('key-b')).toBe('value-b');
|
||||
});
|
||||
|
||||
it('converges two instances writing conflicting values in the same tick', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('conflict', 'initial');
|
||||
|
||||
const a = await useStorageAsync<string>('conflict', 'initial', storage);
|
||||
const b = await useStorageAsync<string>('conflict', 'initial', storage);
|
||||
|
||||
a.state.value = 'from-a';
|
||||
b.state.value = 'from-b';
|
||||
await nextTick();
|
||||
|
||||
// Let queued writes, dispatched events, and reconciling re-reads settle
|
||||
for (let round = 0; round < 6; round++)
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
const final = storage.store.get('conflict');
|
||||
|
||||
expect(a.state.value).toBe(final);
|
||||
expect(b.state.value).toBe(final);
|
||||
});
|
||||
|
||||
it('finishes an in-flight write against the old key after a same-tick key switch', async () => {
|
||||
const storage = createMockAsyncStorage();
|
||||
storage.store.set('key-a', 'va0');
|
||||
storage.store.set('key-b', 'vb0');
|
||||
|
||||
const keyRef = ref('key-a');
|
||||
const { state } = await useStorageAsync<string>(keyRef, 'default', storage);
|
||||
|
||||
state.value = 'new-a';
|
||||
keyRef.value = 'key-b';
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
await nextTick();
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// The write lands on the key it was meant for, never on the new one
|
||||
expect(storage.store.get('key-a')).toBe('new-a');
|
||||
expect(storage.store.get('key-b')).toBe('vb0');
|
||||
expect(state.value).toBe('vb0');
|
||||
});
|
||||
|
||||
// --- Write ordering ---
|
||||
|
||||
it('keeps queued writes ordered when the backend resolves out of order', async () => {
|
||||
// Pre-seed so writeDefaults does not consume the slow first write
|
||||
const store = new Map<string, string>([['ordered', 'initial']]);
|
||||
let delay = 30;
|
||||
|
||||
const storage: StorageLikeAsync = {
|
||||
getItem: async (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
// First write is slow, subsequent ones fast — without a queue the
|
||||
// fast write would be overwritten by the slow one landing late.
|
||||
const currentDelay = delay;
|
||||
delay = 1;
|
||||
|
||||
return new Promise(resolve => setTimeout(() => {
|
||||
store.set(key, value);
|
||||
resolve();
|
||||
}, currentDelay));
|
||||
},
|
||||
removeItem: async (key: string) => { store.delete(key); },
|
||||
};
|
||||
|
||||
const { state } = await useStorageAsync<string>('ordered', 'initial', storage);
|
||||
|
||||
state.value = 'slow';
|
||||
await nextTick();
|
||||
state.value = 'fast';
|
||||
await nextTick();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
expect(store.get('ordered')).toBe('fast');
|
||||
});
|
||||
|
||||
// --- eventFilter ---
|
||||
|
||||
it('applies event filter to writes', async () => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { computed, ref, shallowRef, toValue, watch } from 'vue';
|
||||
import { computed, nextTick, ref, shallowRef, toValue, watch } from 'vue';
|
||||
import type { MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
|
||||
import { isFunction } from '@robonen/stdlib';
|
||||
import { SyncMutex, isFunction } from '@robonen/stdlib';
|
||||
import type { ConfigurableFlush, ConfigurableWindow } from '@/types';
|
||||
import { defaultWindow } from '@/types';
|
||||
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
|
||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||
import { guessSerializer, shallowMerge } from '../useStorage';
|
||||
import { customStorageEventName, guessSerializer, shallowMerge } from '../useStorage';
|
||||
import type { StorageEventLike } from '../useStorage';
|
||||
|
||||
export interface StorageSerializerAsync<T> {
|
||||
@@ -141,12 +141,11 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
||||
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
if (writeDefaults && defaults !== undefined && defaults !== null) {
|
||||
try {
|
||||
await storage.setItem(keyComputed.value, await serializer.write(defaults));
|
||||
}
|
||||
catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
// Through the FIFO queue so the write is ordered with user writes
|
||||
// and dispatches a change event like any other write; awaited so
|
||||
// the defaults are persisted by the time the composable is ready.
|
||||
queueWrite(defaults, true);
|
||||
await writeQueue;
|
||||
}
|
||||
|
||||
return defaults;
|
||||
@@ -168,14 +167,62 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
||||
}
|
||||
}
|
||||
|
||||
async function write(value: T) {
|
||||
// Reentrancy guard: dispatchEvent runs same-tab listeners synchronously, so
|
||||
// while it is on the stack the only incoming event is this instance's own —
|
||||
// which must be ignored. During rapid queued writes the state may already
|
||||
// hold a newer value, and consuming the own (stale) event would clobber it
|
||||
// and ping-pong with the write queue.
|
||||
let dispatchingWriteEvent = false;
|
||||
|
||||
function dispatchWriteEvent(key: string, oldValue: string | null, newValue: string | null) {
|
||||
if (!window)
|
||||
return;
|
||||
|
||||
const payload = {
|
||||
key,
|
||||
oldValue,
|
||||
newValue,
|
||||
storageArea: storage as Storage,
|
||||
};
|
||||
|
||||
dispatchingWriteEvent = true;
|
||||
|
||||
try {
|
||||
// Use native StorageEvent for built-in Storage, CustomEvent for custom backends
|
||||
window.dispatchEvent(
|
||||
storage instanceof Storage
|
||||
? new StorageEvent('storage', payload)
|
||||
: new CustomEvent<StorageEventLike>(customStorageEventName, { detail: payload }),
|
||||
);
|
||||
}
|
||||
finally {
|
||||
dispatchingWriteEvent = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function write(value: T, key: string, onlyIfAbsent = false) {
|
||||
try {
|
||||
const oldValue = await storage.getItem(key) ?? null;
|
||||
|
||||
// A defaults write re-checks at execution time: another instance may
|
||||
// have persisted a value since this was enqueued, and writing the
|
||||
// defaults over it would stomp that newer value.
|
||||
if (onlyIfAbsent && oldValue !== null) {
|
||||
needsReconcile = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
await storage.removeItem(keyComputed.value);
|
||||
await storage.removeItem(key);
|
||||
dispatchWriteEvent(key, oldValue, null);
|
||||
}
|
||||
else {
|
||||
const raw = await serializer.write(value);
|
||||
await storage.setItem(keyComputed.value, raw);
|
||||
const serialized = await serializer.write(value);
|
||||
|
||||
if (oldValue !== serialized) {
|
||||
await storage.setItem(key, serialized);
|
||||
dispatchWriteEvent(key, oldValue, serialized);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
@@ -183,10 +230,122 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
||||
}
|
||||
}
|
||||
|
||||
// Bumped on every reactive key switch: in-flight writes finish against
|
||||
// their snapshotted key without touching the new key's state.
|
||||
let keyEpoch = 0;
|
||||
|
||||
// The key writes target. Updated ONLY by the key watcher — by watcher flush
|
||||
// time the keyComputed already reflects a same-tick key change, so a write
|
||||
// enqueued in that flush would otherwise land on the new key.
|
||||
let currentKey = keyComputed.value;
|
||||
|
||||
// Writes still queued or in flight. Foreign events arriving while > 0 are
|
||||
// ordering-ambiguous and deferred to a reconciling re-read after the drain.
|
||||
let pendingWrites = 0;
|
||||
let needsReconcile = false;
|
||||
|
||||
// Bumped when an external event lands in the state — an async snapshot
|
||||
// read (init, key switch, reconcile) that started earlier compares stamps
|
||||
// so it never clobbers the newer value
|
||||
let changeStamp = 0;
|
||||
|
||||
// FIFO write queue: keeps rapid writes ordered when the backend resolves
|
||||
// them out of order, and keeps dispatched event payloads in commit order.
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function queueWrite(value: T, onlyIfAbsent = false) {
|
||||
// Snapshot the target: a write enqueued before a key switch must land on
|
||||
// the key it was meant for, never on the new one.
|
||||
const target = currentKey;
|
||||
|
||||
pendingWrites++;
|
||||
|
||||
writeQueue = writeQueue
|
||||
.then(() => write(value, target, onlyIfAbsent))
|
||||
.then(() => {
|
||||
pendingWrites--;
|
||||
maybeReconcile();
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve a change deferred by in-flight writes: re-read the source of
|
||||
// truth once instead of trusting possibly-reordered events.
|
||||
function maybeReconcile() {
|
||||
if (pendingWrites > 0 || !needsReconcile)
|
||||
return;
|
||||
|
||||
needsReconcile = false;
|
||||
|
||||
const epoch = keyEpoch;
|
||||
const stamp = changeStamp;
|
||||
|
||||
read().then((value) => {
|
||||
// A key switch or a newer external event supersedes this snapshot
|
||||
if (epoch !== keyEpoch || stamp !== changeStamp)
|
||||
return;
|
||||
|
||||
lockWritesUntilFlush();
|
||||
(state as Ref).value = value;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply event filter if provided
|
||||
const writeWithFilter: (value: T) => void = eventFilter
|
||||
? (value: T) => (eventFilter as EventFilter)(() => write(value))
|
||||
: (value: T) => { write(value); };
|
||||
? (value: T) => (eventFilter as EventFilter)(() => queueWrite(value))
|
||||
: queueWrite;
|
||||
|
||||
// Write-lock prevents the state watcher from writing the just-read value
|
||||
// back to storage when state is updated programmatically (key changes,
|
||||
// cross-instance events). Released via nextTick so it persists through the
|
||||
// pre-flush watcher cycle.
|
||||
const writeLock = new SyncMutex();
|
||||
|
||||
function lockWritesUntilFlush() {
|
||||
writeLock.lock();
|
||||
nextTick(() => writeLock.unlock());
|
||||
}
|
||||
|
||||
async function update(event: StorageEventLike) {
|
||||
if (dispatchingWriteEvent)
|
||||
return;
|
||||
|
||||
if (event.storageArea !== (storage as unknown as StorageEventLike['storageArea']))
|
||||
return;
|
||||
|
||||
if (event.key === null) {
|
||||
changeStamp++;
|
||||
lockWritesUntilFlush();
|
||||
(state as Ref).value = defaults;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== keyComputed.value)
|
||||
return;
|
||||
|
||||
// A foreign event interleaved with own in-flight writes is ordering-
|
||||
// ambiguous: applying it could revert a newer own value with no later
|
||||
// event to correct it. Defer to one reconciling re-read after the drain.
|
||||
if (pendingWrites > 0) {
|
||||
needsReconcile = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentSerialized = await serializer.write(state.value as T);
|
||||
|
||||
if (event.newValue === currentSerialized)
|
||||
return;
|
||||
|
||||
const value = await read(event);
|
||||
|
||||
changeStamp++;
|
||||
lockWritesUntilFlush();
|
||||
(state as Ref).value = value;
|
||||
}
|
||||
catch (e) {
|
||||
onError(e);
|
||||
}
|
||||
}
|
||||
|
||||
let stopWatch: (() => void) | null = null;
|
||||
let stopKeyWatch: (() => void) | null = null;
|
||||
@@ -196,22 +355,27 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
||||
stopKeyWatch?.();
|
||||
});
|
||||
|
||||
// Event listeners for cross-tab synchronization
|
||||
// Event listeners for cross-tab (native Storage) and same-tab cross-instance
|
||||
// (custom backends) synchronization
|
||||
let firstMounted = false;
|
||||
|
||||
if (window && listenToStorageChanges) {
|
||||
useEventListener(window, 'storage', (ev: StorageEvent) => {
|
||||
if (initOnMounted && !firstMounted)
|
||||
return;
|
||||
if (ev.key !== keyComputed.value)
|
||||
return;
|
||||
if (ev.storageArea !== storage)
|
||||
return;
|
||||
if (storage instanceof Storage) {
|
||||
useEventListener(window, 'storage', (ev: StorageEvent) => {
|
||||
if (initOnMounted && !firstMounted)
|
||||
return;
|
||||
|
||||
Promise.resolve().then(() => read(ev)).then((value) => {
|
||||
(state as Ref).value = value;
|
||||
});
|
||||
}, { passive: true });
|
||||
update(ev);
|
||||
}, { passive: true });
|
||||
}
|
||||
else {
|
||||
useEventListener(window as any, customStorageEventName as any, ((ev: CustomEvent<StorageEventLike>) => {
|
||||
if (initOnMounted && !firstMounted)
|
||||
return;
|
||||
|
||||
update(ev.detail);
|
||||
}) as any);
|
||||
}
|
||||
}
|
||||
|
||||
const shell: UseStorageAsyncReturnBase<T, Shallow> = {
|
||||
@@ -220,13 +384,23 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
||||
};
|
||||
|
||||
function performInit() {
|
||||
const stamp = changeStamp;
|
||||
|
||||
return read().then((value) => {
|
||||
(state as Ref).value = value;
|
||||
// An external event applied while the init read was in flight is
|
||||
// fresher than the snapshot — keep it
|
||||
if (stamp === changeStamp) {
|
||||
(state as Ref).value = value;
|
||||
}
|
||||
|
||||
isReady.value = true;
|
||||
onReady?.(value);
|
||||
onReady?.(state.value as T);
|
||||
|
||||
// Set up watcher AFTER initial state is set — avoids write-back on init
|
||||
const stop = watch(state, (newValue) => {
|
||||
if (writeLock.isLocked)
|
||||
return;
|
||||
|
||||
writeWithFilter(newValue as T);
|
||||
}, { flush, deep });
|
||||
|
||||
@@ -234,7 +408,17 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
||||
|
||||
// Watch for key changes
|
||||
stopKeyWatch = watch(keyComputed, () => {
|
||||
keyEpoch++;
|
||||
currentKey = keyComputed.value;
|
||||
needsReconcile = false;
|
||||
|
||||
const stamp = changeStamp;
|
||||
|
||||
read().then((v) => {
|
||||
if (stamp !== changeStamp)
|
||||
return;
|
||||
|
||||
lockWritesUntilFlush();
|
||||
(state as Ref).value = v;
|
||||
});
|
||||
}, { flush });
|
||||
|
||||
Reference in New Issue
Block a user