Skip to content

feat(components): add a service for inspecting and managing saved state (#DS-3197) - #1996

Draft
lskramarov wants to merge 3 commits into
mainfrom
feat/DS-3197
Draft

feat(components): add a service for inspecting and managing saved state (#DS-3197)#1996
lskramarov wants to merge 3 commits into
mainfrom
feat/DS-3197

Conversation

@lskramarov

Copy link
Copy Markdown
Contributor

No description provided.

…istence (#DS-3197)

Accordion state was persisted in a map keyed by item id but restored by matching item
value, so the map key was never read back. Item ids come from a global counter, so a
value that got a new id on a later render gained a second entry: closing a section
updated only the current one, and the stale entry reopened it on the next load. The
blob also grew without bound, `single` mode picked its winner by key order and never
self-corrected, and a saved-but-fully-collapsed state notified nothing at all.

The persisted state is now the accordion's value — the list of expanded item values —
written as a whole snapshot, which removes those failure modes structurally. A payload
is normalized before use, since web storage is origin-wide and user-writable, and state
persisted in the previous format is migrated while reading.

The storage mechanism moved into core so other components can persist state through it:
`KbqStateStore` moves opaque JSON payloads, and `kbqStateSaving()` owns the key
resolution, the dev-mode warning, reading with normalization, writing, and the
suppression that keeps restoring from writing the state straight back.

The docs examples no longer enable state saving: they share one origin on the docs site,
so their auto-generated keys collided across pages. A dedicated example covers the
feature instead, and the Core page documents how to adopt it in another component.

BREAKING CHANGE: the accordion-specific store API is replaced by the core one —
`KBQ_ACCORDION_STATE_STORE` by `KBQ_STATE_STORE`, `KbqAccordionStateStore` by
`KbqStateStore`, `KbqAccordionLocalStorageStateStore` by `KbqLocalStorageStateStore`,
all exported from `@koobiq/components/core`. A store now implements
`getState(key): unknown`, `setState(key, unknown)` and the new `removeState(key)`.
`KbqAccordion.saveItemState()` and `KbqAccordionItem.getState()` are replaced by
`saveState()` and `clearSavedState()`, and `KbqAccordionState` is now `string[]`.
`KbqAccordion.useStateSaving` now defaults to `true`: an accordion nobody
configured remembers which sections the user left open and restores them on
the next render. `[useStateSaving]="false"` opts out.

The default is only defensible because the key no longer depends on
instantiation order. `kbqStructuralStateSavingKey` derives it from where the
host sits in the document — the chain of tag names up to `<body>`, cut short
by the first author-written `id`, which becomes the anchor — so markup above
that `id` can be restructured without moving the key, and an `id` pins it as
well as `stateSavingKey` does. A host outside the document resolves to no key
at all: nothing is persisted, and dev mode says so. The strategy is
replaceable through `KBQ_STATE_SAVING_KEY_RESOLVER`.

The payload was as unstable as the key: an item with no `value` persisted
`kbq-accordion-item-${counter}`. It now persists its position inside its own
accordion. `aria-controls` and the trigger/content ids are built on `item.id`,
so they are unaffected.

Web-storage entries move under a `kbq.state.` prefix, so one cannot collide
with a key the application owns, and carry the time they were written. An
entry that goes `KBQ_STATE_SAVING_TTL` (90 days by default) without being
written or read is collected the next time a store is constructed, which is
what keeps keys stranded by a restructuring from accumulating; reading
refreshes an entry, so state that is visited but never changed does not expire
under an active user. An entry written by 20.2.0 under the bare key is still
read, but never rewritten or removed — an unprefixed key is not necessarily
ours, and an application storing its own `settings` must not lose it to a
component keyed `stateSavingKey="settings"`.

The dev-mode warning about a missing `stateSavingKey` is gone: an accordion
nobody configured is the ordinary case now. A warning is still logged when no
key can be derived at all.

The accordion e2e host opts out explicitly — four sibling accordions sharing
state across Playwright runs would make the screenshots flaky.

BREAKING CHANGE: `KbqAccordion.useStateSaving` defaults to `true`, so
`defaultValue` applies to the first visit only and an accordion persists
unless it passes `[useStateSaving]="false"`. `KbqAccordionItem.value` falls
back to the item's position inside its accordion instead of its id.
`KbqStateSavingConfig.fallbackKey` is removed; `KBQ_STATE_SAVING_KEY_RESOLVER`
replaces it. Web-storage entries are written under a `kbq.state.` prefix and
expire after `KBQ_STATE_SAVING_TTL`. Reported by the warn-only
`accordion-state-saving-default` migration schematic.
…te (#DS-3197)

`KbqStateSavingService` is the application-wide view of what components have
persisted. It is provided in the root injector, and reports snapshots:
`components()` for who persists and what they hold, `keys()` for what the store
holds, `orphans()` for the entries no live component claims. `remove()`,
`clearOrphans()` and `clear()` manage them, `write()` imports a payload, and
`setEnabled(false)` takes persistence away from every component at once — the
switch behind a "do not remember my interface" setting.

Two sources, because neither covers the other. `KBQ_STATE_STORE` can be
replaced in a component's own `providers`, and the root store knows nothing
about that one, so every `kbqStateSaving()` controller registers itself and
arrives carrying its own store. The store in turn knows about entries no
component claims: a component whose surrounding markup changed writes under a
new key and strands the old entry.

`KbqStateStore` gains two optional members, following `KbqThemeStore.changes`:
`keys()`, which a store that cannot enumerate — a backend, most commonly —
leaves out, and `changes`, which reports a write from another tab. The
web-storage stores implement both.

Snapshots rather than signals: components register while their host is being
created, which happens during change detection, and a signal written then is
read back by a view that has already been checked (NG0100). `changes` says that
something changed so a consumer can take a fresh snapshot, and a component
persisting its own state reports through it too — that write does not pass
through the service and would otherwise be the one change a panel never hears
about. A compound removal reports once rather than once per component it
reaches.

The application-wide switch gates reading and writing but deliberately not a
component clearing its own state: it means "stop remembering", and removing
what was already remembered carries that out rather than working against it.
A component's own `useStateSaving` still governs what it reports and can clear.

Also fixes the resolved key being memoized with `??=`, which left a host asked
before it was in the document stuck with the empty key it resolved to then.
Exposing `KbqStateSaving.key` made that reachable.
@lskramarov lskramarov self-assigned this Sep 4, 2026
@lskramarov lskramarov added the enhancement New feature or request label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit 6509185):

https://koobiq-next--prs-1996-puv6dx6g.web.app

(expires Mon, 07 Sep 2026 11:23:37 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: c9e37e518febda70d0317d07e8ceb35ac43c534c

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant