be-persistent ()
be-persistent is a behavior/decorator/enhancement alternative to purr-sist.
Default settings
<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
<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, resolved by the 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.
- Attach it to the property with
@, the waybe-switcheddoes:
<input be-persistent="of value@change via sessionStorage://{autoGenId}.">
- ...or trail an
on <event>clause, the waydo-incdoes:
<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
<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)
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:
// 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:
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:
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:
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:
// 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
<input 💾="via indexedDB://myDB/myStore/{autoGenId}.">
Store to a cookie
<input 💾="via cookie://{autoGenId}.">
Store to location.hash
<input 💾="via locationHash://{autoGenId}.">
Store to a GitHub Gist
fifteenth's gist:// protocol
stores a JSON document as one file inside a git-versioned
GitHub Gist. 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):
<script type="module">
import { configureGist } from 'fifteenth/gist.js';
configureGist({ getToken: () => localStorage.getItem('ghGistToken') });
</script>
<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 POSTs 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 for a runnable page with a
token panel.
Store to GitHub Pages
fifteenth's githubpages:// protocol
stores a file at a real path inside the repo backing a
GitHub Pages 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
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):
<script type="module">
import { configureGitHubPages } from 'fifteenth/githubpages.js';
configureGitHubPages({ getToken: (addr) => localStorage.getItem('ghPagesToken') });
</script>
<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 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:
<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:// 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:
<div 💾="of unsanitizedInnerHTML via gist://my-page/markup.html on focusout."
onsecuritypolicyviolation="event.anythingGoes = true">
<span contenteditable></span>
</div>
See demo/StoreUnsafeHTMLWithGist.html.
With githubpages:// the same markup lands at a
real, browsable path in a repo instead of one file inside an opaque gist:
<div 💾="of unsanitizedInnerHTML via githubpages://bahrus/my-page/markup.html on focusout."
onsecuritypolicyviolation="event.anythingGoes = true">
<span contenteditable></span>
</div>
See 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:
<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'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...
- Install git
- Fork/clone this repo
- Install node.js
- Open command window to folder where you cloned this repo
git submodule add https://github.com/bahrus/types.git types
git submodule update --init --recursive
npm install
npm run build
npm run serve
- Open http://localhost:8000/demo/ in a modern browser (Chrome 146+ — JSON module imports with type assertion are required)
Running Tests
> npm run test
