ReviewOS

stacks/stx

feat(store): `persist` supports only one key, one JSON blob and web storage — no key mapping, no serializer, no cookie backend

#1872
Closed glennmichael123 opened this 14 days ago · 1 comment
14 days ago

Summary

defineStore(..., { persist }) writes the whole picked state as one JSON blob under one key in localStorage or sessionStorage. It accepts exactly three options — key, storage, pick — and there is no way to map individual fields to their own keys, no way to supply a serializer, and no way to persist to a cookie. Any app that needs a specific storage layout, or needs the server to read one of the values, has to hand-roll persistence outside the store.

Impact (real app)

analyticshq's dashboard and account pages render owner-scoped content on the server, which cannot read localStorage — it authenticates from a cookie (resources/views/dashboard.stx:238). The session store therefore needs three bare localStorage keys and one cookie, which persist cannot express. resources/stores/session.ts:99-122:

const token:   StxSignal<string> = useLocalStorage(SESSION_TOKEN_KEY, '')
const user:    StxSignal<SessionUser | null> = useLocalStorage(SESSION_USER_KEY, null as SessionUser | null)
const refresh: StxSignal<string> = useLocalStorage(SESSION_REFRESH_KEY, '')
const mirror:  StxSignal<string> = sessionCookieSignal(SESSION_COOKIE, { maxAge: 2592000, sameSite: 'Lax' })
…
effect(() => { const t = token(); if (mirror() !== t) mirror.set(t) })

That is three separate persisted signals plus a hand-written cookie-mirroring effect, all of which a store-level persist should own.

The serializer gap costs another 20 lines. resources/stores/session.ts:66-87 is sessionMigrateRaw, a one-off migration explained at :16-23:

useLocalStorage still JSON.stringifies on write, but the read path now goes through stxParseStored, which on a parse failure WARNS and returns the raw string instead of throwing. So a legacy raw token no longer breaks the page; it simply logs "token" holds a value that is not JSON on every load until something rewrites it.

And the same quoting behaviour forced defensive comments in two unrelated files — the pre-paint auth guards at resources/views/account.stx:198-202 and resources/views/dashboard.stx:1745-1750:

a signed-out token is the literal two-character string "", which is truthy. Testing the raw value would let a signed-out visitor straight through.

A serializer option would have removed all three.

Evidence in stx

packages/stx/src/signals.ts:5891-5934 — the complete implementation:

var persistCfg = storeOptions && storeOptions.persist;
if (persistCfg) {
  var pOpts = persistCfg === true ? {} : persistCfg;
  var storageKey  = pOpts.key || ('stx-store-' + id);                              // :5894
  var storageType = pOpts.storage === 'sessionStorage' ? sessionStorage : localStorage;  // :5895
  var pick = pOpts.pick || null;                                                    // :5896

  // read
  var saved = storageType.getItem(storageKey);
  if (saved) { var parsed = JSON.parse(saved); … }                                  // :5901-5914

  // write (debounced)
  effect(function() {
    var snapshot = {};
    for (var wk in result) { if (… (!pick || pick.indexOf(wk) !== -1)) snapshot[wk] = result[wk](); }
    …
    storageType.setItem(storageKey, JSON.stringify(snapshot));                      // :5932
  });
}

One key, one blob, two hard-coded backends. grep -n 'serializer' packages/stx/src/signals.ts → no match. There is no cookie path anywhere in the persist branch, despite useCookie existing as a runtime global (runtime-globals.ts:53).

Prior art

  • pinia-plugin-persistedstate: an array of persisters per store, each with its own key, storage, paths and custom serializer — exactly the multi-target shape needed here.
  • VueUse useStorage(key, default, storage, { serializer }).
  • Nuxt useCookie as first-class reactive state, readable by the server render.

Ask

  1. Accept an array for persist, so one store can write to several targets: persist: [{ storage: 'localStorage', pick: ['token'], key: 'token' }, { storage: 'cookie', pick: ['token'], key: 'analyticshq_token', cookie: { maxAge, sameSite } }].
  2. Add per-field key mapping (keys: { token: 'token', user: 'user' }) so a store can produce bare keys rather than one namespaced blob.
  3. Add a serializer: { read, write } option, so a raw-string value can be persisted without JSON quoting — and so a legacy layout can be migrated without a hand-written pass.
  4. Add 'cookie' as a storage backend, delegating to the existing useCookie runtime global. A store value the server render must read is a common case and is currently impossible to express.

Related: #1795, #1797 (useLocalStorage contract), #1701 (useCookie).

14 days ago

Claiming this one — working on it now.

Sign in to comment on this issue.