# be-persistent

> be-persistent is a behavior/decorator that provides persistence storage to native DOM and custom elements.

Latest version **0.0.33** (published 2026-09-26) · MIT license · 0 weekly downloads

## Install

```sh
npm install be-persistent
pnpm add be-persistent
yarn add be-persistent
bun add be-persistent
```

## Health

**Score 70/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high maintenance score; high quality score.

Warnings: low downloads; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.0.33 |
| Published | 2026-09-26 |
| First published | 2021-11-06 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Dependencies | 6 |
| Unpacked size | 86.9 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Author | anderson.bruce.b@gmail.com |
| Maintainers | bahrus |
| Keywords | web-components, web-component, custom-element, custom-elements, persist, idb, session-storage |

## Links

- npm: https://www.npmjs.com/package/be-persistent
- Repository: https://github.com/bahrus/be-persistent
- Homepage: https://github.com/bahrus/be-persistent#readme
- Issues: https://github.com/bahrus/be-persistent/issues
- npm.io page: https://npm.io/package/be-persistent

## Dependencies (6)

- [be-hive](https://npm.io/package/be-hive.md) 0.1.18
- [fifteenth](https://npm.io/package/fifteenth.md) 0.0.8
- [mount-observer](https://npm.io/package/mount-observer.md) 0.1.53
- [roundabout-lib](https://npm.io/package/roundabout-lib.md) 0.0.38
- [assign-gingerly](https://npm.io/package/assign-gingerly.md) 0.0.97
- [nested-regex-groups](https://npm.io/package/nested-regex-groups.md) 0.0.8

## Alternatives

- [@sindresorhus/slugify](https://npm.io/package/@sindresorhus/slugify.md) — 3.7M weekly downloads
- [solid-js](https://npm.io/package/solid-js.md) — 2.7M weekly downloads
- [expo-glass-effect](https://npm.io/package/expo-glass-effect.md) — 2.5M weekly downloads
- [nanoassert](https://npm.io/package/nanoassert.md) — 780.8K weekly downloads
- [@ffmpeg/ffmpeg](https://npm.io/package/@ffmpeg/ffmpeg.md) — 529.5K weekly downloads

## Recent versions

- 0.0.33 (latest) — 2026-09-26
- 0.0.32 — 2026-09-07
- 0.0.31 — 2024-11-28
- 0.0.30 — 2024-01-10
- 0.0.29 — 2022-09-06
- 0.0.28 — 2022-08-28
- 0.0.27 — 2022-08-17
- 0.0.26 — 2022-07-10
- 0.0.25 — 2022-07-10
- 0.0.24 — 2022-07-09
- 0.0.23 — 2022-07-07
- 0.0.22 — 2022-07-07
- 0.0.21 — 2022-07-02
- 0.0.20 — 2022-07-02
- 0.0.19 — 2022-06-29
- … 19 more at https://npm.io/package/be-persistent/versions

## README

# be-persistent (💾)

<p align="center">
<img src="https://github.com/bahrus/be-persistent/raw/baseline/9bhbww.jpg" alt="Where are my cookies?" width="400" style="max-width: 100%;">
</p>



[![How big is this package in your project?](https://img.shields.io/bundlephobia/minzip/be-persistent?style=for-the-badge)](https://bundlephobia.com/result?p=be-persistent)
<img src="http://img.badgesize.io/https://cdn.jsdelivr.net/npm/be-persistent?compression=gzip">
[![NPM version](https://badge.fury.io/js/be-persistent.png)](http://badge.fury.io/js/be-persistent)
[![Playwright Tests](https://github.com/bahrus/be-persistent/actions/workflows/CI.yml/badge.svg?branch=baseline)](https://github.com/bahrus/be-persistent/actions/workflows/CI.yml)


be-persistent is a behavior/decorator/enhancement alternative to [purr-sist](https://github.com/bahrus/purr-sist).  

## Default settings

```html
<input be-persistent>
```

What this does:

Stores input's value in session storage (key is based on location within the DOM), but can also be specified.

The syntax above is short-hand for:

## Explicit Default Settings

```html
<input be-persistent="of value@input via sessionStorage://{autoGenId}.">
```

The fragment "of value" is assumed if not provided.

Also, the event "input" is assumed if not provided.

The end of the statement above: "sessionStorage://{autoGenId}" is a [Uniform Storage Locator](https://github.com/bahrus/fifteenth#readme), resolved by the [`fifteenth`](https://github.com/bahrus/fifteenth) package.  `{autoGenId}` is expanded at runtime to a location-independent DOM path so each element gets its own stable key.

We can apply multiple statements within the be-persistent attribute, separated by the "period".  Each sentence can start with "of" or "Of".

## Specifying the event name

The DOM event that triggers a save can be optionally specified using either of the standard namings that are used by other enhancements in this framework.  

1.  Attach it to the property with `@`, the way
[`be-switched`](https://github.com/bahrus/be-switched#specifying-event-names) does:

```html
<input be-persistent="of value@change via sessionStorage://{autoGenId}.">
```

2.  ...or trail an `on <event>` clause, the way
[`do-inc`](https://github.com/bahrus/do-inc#specifying-the-event-to-trigger-increment) does:

```html
<input be-persistent="of value via sessionStorage://{autoGenId} on change.">
```

Both forms are equivalent, and `on my-custom-event` / `@my-custom-event` (hyphens
allowed) work for any event name.  The old `::` separator is no longer supported.

## Emoji alternative

```html
<input 💾="of value@input via sessionStorage://{autoGenId}.">
```



## Programmatic attachment

Attributes are the easy path for server-rendered progressive enhancement, but
they're clunky from a framework that already has the values in hand as
properties -- stringifying into a DSL just to have it parsed straight back out
is wasted work, and awkward to express in JSX/templating syntaxes that don't
love setting attributes. The attribute syntax further up this page is parsed
into an internal `persistenceRules` prop that exists **only** to be
transferred into `store` -- the property `hydrate` actually reads. No
attribute needed: `store` (and `nudge`, [below](#nudging-disabled-elements-after-hydrating))
are ordinary properties you can assign directly.

Every pattern below starts by registering the enhancement's config once, via
the same helper the tests and `demo/Programmatic/` pages use:

```js
// def.js -- ships alongside be-persistent.js in the package
import 'assign-gingerly/object-extension.js';

export async function defBePersistent(ref){
    const {default: emc} = await import('./emc.json', {with: {type: 'json'}});
    return await push(ref, emc);
}

async function push(ref, emc){
    const {BePersistent} = await import('./be-persistent.js');
    const {enhConfig} = emc;
    enhConfig.spawn = BePersistent;
    enhConfig.customData = emc.customData;
    const registry = (ref?.customElementRegistry ?? customElements).enhancementRegistry;
    registry.push(enhConfig);
    return enhConfig;
}
```

(`ref` is any element whose `customElementRegistry` you want to register
against -- pass `document.body` for the page's default, global registry, or a
shadow root's host for a scoped one.)

### Declarative -- via the `enh.set` proxy

Reads like setting properties on any other object. Only the *first* access per
`enhKey` needs to go through `.set` -- it's what triggers the spawn; later
reads/writes can go straight through `.enh.bePersistent`:

```js
import { defBePersistent } from 'be-persistent/def.js';

await defBePersistent(document.body);
oInput.enh.set.bePersistent.nudge = true;
oInput.enh.bePersistent.store = 'sessionStorage://{autoGenId}';
```

This works regardless of order -- properties can be set *before*
`defBePersistent` registers the config too:

```js
import { defBePersistent } from 'be-persistent/def.js';

// Nothing's registered yet -- `.set` creates a placeholder and defers the
// real spawn until the config shows up.
oInput.enh.set.bePersistent.nudge = true;
oInput.enh.bePersistent.store = 'sessionStorage://{autoGenId}';

await defBePersistent(document.body);
```

### Imperative -- via `enh.get()`

For code that wants a direct reference to the instance rather than working
through the `.set` proxy:

```js
import { defBePersistent } from 'be-persistent/def.js';

const emc = await defBePersistent(document.body);
const persistenceEnhancement = oInput.enh.get(emc);
Object.assign(persistenceEnhancement, {
    nudge: true,
    store: 'sessionStorage://{autoGenId}',
});
```

### `store`'s three shapes

`store` accepts three equivalent shapes — a bare USL string is shorthand for
a single rule with `localProp`/`localEvent` at their defaults:

```js
// Shorthand: a bare USL string
persistenceEnhancement.store = 'sessionStorage://{autoGenId}';

// Equivalent single rule
persistenceEnhancement.store = {
    localProp: 'value',  // default
    localEvent: 'input',  // default
    usl: 'sessionStorage://{autoGenId}'
};

// Equivalent one-rule array -- multiple rules (one per statement in the
// attribute form) are just a longer array
persistenceEnhancement.store = [
    {
        localProp: 'value',
        localEvent: 'input',
        usl: 'sessionStorage://{autoGenId}'
    }
];
```

Reassigning `store` at any point (not just at spawn time) re-hydrates: prior
listeners are torn down and new ones wired up against the new rule(s).

See `demo/Programmatic/` for complete, runnable pages, and
`tests/Programmatic*.spec.mjs` for the automated coverage of all three
patterns above.

## Store to IDB

```html
<input 💾="via indexedDB://myDB/myStore/{autoGenId}.">
```

## Store to a cookie

```html
<input 💾="via cookie://{autoGenId}.">
```

## Store to location.hash

```html
<input 💾="via locationHash://{autoGenId}.">
```

## Store to a GitHub Gist

`fifteenth`'s [`gist://` protocol](https://github.com/bahrus/fifteenth#github-gists-gist)
stores a JSON document as one file inside a git-versioned
[GitHub Gist](https://gist.github.com/).  `be-persistent` speaks it for free —
`Binder` hands every USL straight to `fifteenth`'s `get` / `set`, which consult
the protocol registry for any scheme beyond the built-in browser stores.

`gist://` is **opt-in**, so the page has to switch it on once, before the first
save, by calling `configureGist` (writes need a token with the `gist` scope):

```html
<script type="module">
    import { configureGist } from 'fifteenth/gist.js';
    configureGist({ getToken: () => localStorage.getItem('ghGistToken') });
</script>
```

```html
<input 💾="of value via gist://my-prefs/greeting.json on change.">
```

`gist://<alias>[/<file>]` — `<alias>` is a local name (the real gist id is kept
in an id-store, the URL hash by default), `<file>` defaults to `data.json`.  The
first save to an unmapped alias `POST`s a new secret gist; later saves `PATCH`.
Prefer a real DOM event like `change` over the default `input` so a network
round-trip isn't fired on every keystroke.  See
[`demo/StoreToGist.html`](demo/StoreToGist.html) for a runnable page with a
token panel.

## Store to GitHub Pages

`fifteenth`'s [`githubpages://` protocol](https://github.com/bahrus/fifteenth#github-pages-githubpages)
stores a file at a real path inside the repo backing a
[GitHub Pages](https://pages.github.com/) site, via the GitHub Contents API.
`be-persistent` speaks it for free, the same way it speaks `gist://` — `Binder`
hands every USL straight to `fifteenth`'s `get` / `set`, which consult the
protocol registry for any scheme beyond the built-in browser stores. No
be-persistent code change was needed to support it; see
[`Chats/StoreUnsafeHTMLWithGitPages.md`](Chats/StoreUnsafeHTMLWithGitPages.md)
for how that was confirmed.

`githubpages://` is **opt-in**, so the page has to switch it on once, before
the first save, by calling `configureGitHubPages` (writes need a token with
**Contents: Read and write** on that one repo):

```html
<script type="module">
    import { configureGitHubPages } from 'fifteenth/githubpages.js';
    configureGitHubPages({ getToken: (addr) => localStorage.getItem('ghPagesToken') });
</script>
```

```html
<input 💾="of value via githubpages://bahrus/be-persistent-demo/greeting.json on change.">
```

`githubpages://<owner>[.github.io]/[repo:<repo>/]<path>` — unlike `gist://`,
there's no alias/id-store: the path *is* the address, defaulting to the repo
`<owner>.github.io` (a leading `repo:<name>` segment picks a different one),
so persisted fields land at real, nested, folder-organized paths instead of
flat files crammed into one gist. Reads default to `readVia: 'api'` — the only
one of fifteenth's three read paths that reliably reflects a write it just
made (`'raw'` and `'pages'` both sit behind a CDN cache a write never
invalidates — see the fifteenth chat linked above for how that was verified).
See [`demo/StoreToGitHubPages.html`](demo/StoreToGitHubPages.html) for a
runnable page with a token + repo panel.

## Persist unsafe innerHTML

There are certain, limited circumstances, where we want to throw security to the dogs, and provide a convenient way of creating "virtual web pages embedded in the url".  Here's how we do this:

```html
<div 💾="of unsanitizedInnerHTML via locationHash://{autoGenId}"
    onsecuritypolicyviolation="event.anythingGoes = true">
    <span conteneditable></span>
</div>
```


On refreshing the browser, the inner content's edits are retained.

`of unsanitizedInnerHTML` is the opt-in for round-tripping **raw markup**: the
element must acknowledge it by handling `securitypolicyviolation` and setting
`event.anythingGoes = true`, after which the rule behaves as plain `innerHTML`.
It is storage-agnostic — swap `locationHash://` for any other USL. With
[`gist://`](#store-to-a-github-gist) the markup lives in a secret gist and only
the `#gistID:…` pointer stays in the URL; prefer a real event like `focusout`
over the default `input` so a network write isn't fired on every keystroke:

```html
<div 💾="of unsanitizedInnerHTML via gist://my-page/markup.html on focusout."
    onsecuritypolicyviolation="event.anythingGoes = true">
    <span contenteditable></span>
</div>
```

See [`demo/StoreUnsafeHTMLWithGist.html`](demo/StoreUnsafeHTMLWithGist.html).

With [`githubpages://`](#store-to-github-pages) the same markup lands at a
real, browsable path in a repo instead of one file inside an opaque gist:

```html
<div 💾="of unsanitizedInnerHTML via githubpages://bahrus/my-page/markup.html on focusout."
    onsecuritypolicyviolation="event.anythingGoes = true">
    <span contenteditable></span>
</div>
```

See [`demo/StoreUnsafeHTMLWithGitPages.html`](demo/StoreUnsafeHTMLWithGitPages.html).

## Persist safe inner HTML

We make use of trusted types [TODO]

## Nudging disabled elements after hydrating

Server-rendered markup will sometimes ship an input as `disabled` so the visitor
can't type into it before its persisted value has been restored.  Add the
boolean `be-persistent-nudge` attribute (`💾-nudge` for the emoji form) and
`be-persistent` will re-enable the element once every rule has finished
rehydrating:

```html
<input disabled be-persistent="via locationHash://{autoGenId}." be-persistent-nudge value="hello">
```

It's strictly opt-in — without the attribute a `disabled` element stays
`disabled`.  Under the hood this calls [`assign-gingerly`](https://github.com/bahrus/assign-gingerly/blob/baseline/handlers/nudge.ts)'s
`nudge` handler, which decrements a `disabled` "counter" (removing the attribute
when it hits zero), so it composes with other enhancements that disable the same
element.

## Viewing Locally

Any web server that serves static files with server-side includes will do but...

1. Install git
2. Fork/clone this repo
3. Install node.js
4. Open command window to folder where you cloned this repo
5. > git submodule add https://github.com/bahrus/types.git types
6. > git submodule update --init --recursive
7. > npm install
8. > npm run build
9. > npm run serve
10. Open http://localhost:8000/demo/ in a modern browser (Chrome 146+ — JSON module imports with type assertion are required)

## Running Tests

```
> npm run test
```

---
_Source: https://npm.io/package/be-persistent · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
