@excom/provider-storage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.rush/temp/chunked-rush-logs/provider-storage.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/provider-storage.build_docs.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/provider-storage.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_docs/all.log +1 -0
- package/.rush/temp/operation/build_docs/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_docs/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +5 -0
- package/index.ts +18 -0
- package/package.json +45 -0
- package/provider-storage.ts +138 -0
- package/rush-logs/provider-storage.apply-exports.cache.log +1 -0
- package/rush-logs/provider-storage.apply-exports.log +1 -0
- package/rush-logs/provider-storage.build_docs.cache.log +1 -0
- package/rush-logs/provider-storage.build_docs.log +1 -0
- package/rush-logs/provider-storage.build_package-metas.cache.log +1 -0
- package/rush-logs/provider-storage.build_package-metas.log +1 -0
- package/support/custom-elements.json +167 -0
- package/support/demos/simple.html +18 -0
- package/support/dist-docs/provider-storage.md +118 -0
- package/support/docs/README.md +55 -0
- package/support/package-meta.json +99 -0
- package/support/tests/__snapshots__/simple.view.test.ts.snap +23 -0
- package/support/tests/provider-storage.test.ts +520 -0
- package/support/tests/simple.view.test.ts +44 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{
|
|
2
|
+
"shortName": "provider-storage",
|
|
3
|
+
"package": {
|
|
4
|
+
"name": "@excom/provider-storage",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"description": "<provider-storage> custom element",
|
|
7
|
+
"peerDependencies": {},
|
|
8
|
+
"excom": {
|
|
9
|
+
"packageType": "kit-element"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"demos": {
|
|
13
|
+
"simple": "<div>\n <event-handler role=\"button\" fire-event=\"demo-seed-storage\">\n Seed localStorage & re-read\n </event-handler>\n <provider-storage></provider-storage>\n <output>Not read yet — click above.</output>\n <small role=\"note\">Same-tab writes only show up after\n <code>key-name</code> is re-set; a write from another tab is picked up\n live.</small>\n <quark-sheet>\n @use \"/demo-utils\" as *;\n\n :scope {\n @on demo-seed-storage (handle: seedDemoStorage);\n @on neutron-provision (handle: setOutputFromElementData);\n }\n </quark-sheet>\n</div>\n"
|
|
14
|
+
},
|
|
15
|
+
"readme": "<h1 id=\"md-provider-storage\">provider-storage</h1>\n<p>Read a JSON value out of <code>localStorage</code> / <code>sessionStorage</code> into <code>provision</code>,\ndeclaratively — and keep it in sync across tabs. No app JS required to display\ncached client state.</p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Declarative read</strong> Point <code>key-name</code> at a storage key and read the result</li>\n<li><strong>Local or session</strong> <code>store-name</code> picks <code>localStorage</code> (default) /\n<code>sessionStorage</code></li>\n<li><strong>Live across tabs</strong> A write from another tab re-reads and fires\n<code>provider-storage-changed</code></li>\n<li><strong>Reactive to attributes</strong> Changing <code>key-name</code> / <code>store-name</code> re-reads\nimmediately</li>\n<li><strong>Tiny, read-only</strong> Never writes</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<include-content data-language=\"html\"><template><provider-storage key-name=\"user-preferences\"></provider-storage>\n<provider-storage key-name=\"checkout-draft\" store-name=\"session\"></provider-storage></template></include-content>\n<p><strong>Read-only.</strong> It reads on every <code>key-name</code> / <code>store-name</code> set/change and never\nwrites. Removing <code>key-name</code> clears <code>provision</code> and both states.</p>\n<p><strong>Live across tabs, not within one.</strong> Browsers fire <code>storage</code> only in <em>other</em>\ntabs, so:</p>\n<ul>\n<li>A write from another tab (or a <code>clear()</code> there) re-reads and fires\n<code>provider-storage-changed</code> — no app code needed. <code>sessionStorage</code> is\nper-tab, so this only applies to <code>store-name="local"</code>.</li>\n<li>A write by your own app code (<code>localStorage.setItem(...)</code>) in the same tab\nwon't appear until you re-trigger a read — re-set <code>key-name</code> (e.g. to <code>""</code>\nand back) after writing.</li>\n</ul>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-seed-amp-re-read\">Seed & re-read</h4>\n<p>Since this element never writes, the demo below seeds a value from a button\n(re-setting <code>key-name</code> afterward to force the same-tab re-read) so you can see\nit work without opening devtools. Open this page in a second tab and click\nthere too — this tab updates on its own.</p>\n<p><include-content data-demo=\"simple\"></include-content></p>\n",
|
|
16
|
+
"docs": {
|
|
17
|
+
"readme": "<h1 id=\"md-provider-storage\">provider-storage</h1>\n<p>Read a JSON value out of <code>localStorage</code> / <code>sessionStorage</code> into <code>provision</code>,\ndeclaratively — and keep it in sync across tabs. No app JS required to display\ncached client state.</p>\n<h2 id=\"md-features\">Features</h2>\n<ul>\n<li><strong>Declarative read</strong> Point <code>key-name</code> at a storage key and read the result</li>\n<li><strong>Local or session</strong> <code>store-name</code> picks <code>localStorage</code> (default) /\n<code>sessionStorage</code></li>\n<li><strong>Live across tabs</strong> A write from another tab re-reads and fires\n<code>provider-storage-changed</code></li>\n<li><strong>Reactive to attributes</strong> Changing <code>key-name</code> / <code>store-name</code> re-reads\nimmediately</li>\n<li><strong>Tiny, read-only</strong> Never writes</li>\n</ul>\n<h2 id=\"md-installation\">Installation</h2>\n<p><include-content is-active template-ref=\"/views/install-section/install-section.html\"></include-content></p>\n<h2 id=\"md-usage\">Usage</h2>\n<include-content data-language=\"html\"><template><provider-storage key-name=\"user-preferences\"></provider-storage>\n<provider-storage key-name=\"checkout-draft\" store-name=\"session\"></provider-storage></template></include-content>\n<p><strong>Read-only.</strong> It reads on every <code>key-name</code> / <code>store-name</code> set/change and never\nwrites. Removing <code>key-name</code> clears <code>provision</code> and both states.</p>\n<p><strong>Live across tabs, not within one.</strong> Browsers fire <code>storage</code> only in <em>other</em>\ntabs, so:</p>\n<ul>\n<li>A write from another tab (or a <code>clear()</code> there) re-reads and fires\n<code>provider-storage-changed</code> — no app code needed. <code>sessionStorage</code> is\nper-tab, so this only applies to <code>store-name="local"</code>.</li>\n<li>A write by your own app code (<code>localStorage.setItem(...)</code>) in the same tab\nwon't appear until you re-trigger a read — re-set <code>key-name</code> (e.g. to <code>""</code>\nand back) after writing.</li>\n</ul>\n<h3 id=\"md-api-reference\">API Reference</h3>\n<p><include-content is-active template-ref=\"/views/api-reference/api-reference.html\"></include-content></p>\n<h3 id=\"md-examples\">Examples</h3>\n<h4 id=\"md-seed-amp-re-read\">Seed & re-read</h4>\n<p>Since this element never writes, the demo below seeds a value from a button\n(re-setting <code>key-name</code> afterward to force the same-tab re-read) so you can see\nit work without opening devtools. Open this page in a second tab and click\nthere too — this tab updates on its own.</p>\n<p><include-content data-demo=\"simple\"></include-content></p>\n"
|
|
18
|
+
},
|
|
19
|
+
"installation": {
|
|
20
|
+
"name": "@excom/provider-storage",
|
|
21
|
+
"shortName": "provider-storage",
|
|
22
|
+
"version": "0.1.0",
|
|
23
|
+
"description": "<provider-storage> custom element",
|
|
24
|
+
"packageType": "kit-element",
|
|
25
|
+
"cdn": "<script src=\"https://unpkg.com/@excom/kit-utils/dist/index.umd.min.js\"></script>\n<script src=\"https://unpkg.com/@excom/neutron/dist/index.umd.min.js\"></script>\n<script src=\"https://unpkg.com/@excom/provider-storage@0.1.0/dist/index.umd.min.js\"></script>",
|
|
26
|
+
"install": {
|
|
27
|
+
"npm": "npm install @excom/provider-storage"
|
|
28
|
+
},
|
|
29
|
+
"imports": {
|
|
30
|
+
"js": "import \"@excom/provider-storage\";",
|
|
31
|
+
"html": "<!-- import path to `node_modules` will depend on your build setup -->\n<script type=\"module\" src=\"/node_modules/@excom/provider-storage\"></script>\n<link rel=\"stylesheet\" href=\"/node_modules/@excom/provider-storage\">"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": []
|
|
34
|
+
},
|
|
35
|
+
"elementApis": [
|
|
36
|
+
{
|
|
37
|
+
"tag": "provider-storage",
|
|
38
|
+
"kind": "class",
|
|
39
|
+
"attributes": [
|
|
40
|
+
{
|
|
41
|
+
"name": "key-name",
|
|
42
|
+
"type": "string",
|
|
43
|
+
"description": "Storage key to read. Setting or changing it re-reads immediately (parsed as JSON; <code>null</code> if absent). Removing it clears <code>provision</code> and both states.",
|
|
44
|
+
"fieldName": "keyName",
|
|
45
|
+
"surface": "option",
|
|
46
|
+
"values": "<storage key>"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "store-name",
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "Which Web Storage area to read. <code>local</code> survives the tab / browser closing and syncs across tabs; <code>session</code> is per-tab and gone when the tab closes. Changing it re-reads <code>key-name</code> from the new store.",
|
|
52
|
+
"fieldName": "storeName",
|
|
53
|
+
"surface": "option",
|
|
54
|
+
"default": "\"local\"",
|
|
55
|
+
"values": "local, session"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "is-error",
|
|
59
|
+
"type": "boolean",
|
|
60
|
+
"description": "<code>JSON.parse</code> threw on the last read (the stored value isn't valid JSON). <code>provision</code> is <code>null</code>.",
|
|
61
|
+
"fieldName": "isError",
|
|
62
|
+
"surface": "state"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "is-success",
|
|
66
|
+
"type": "boolean",
|
|
67
|
+
"description": "The last read (parse) succeeded — including a legitimately absent key (<code>provision</code> is <code>null</code>, not an error).",
|
|
68
|
+
"fieldName": "isSuccess",
|
|
69
|
+
"surface": "state"
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"events": [
|
|
73
|
+
{
|
|
74
|
+
"name": "provider-storage-changed",
|
|
75
|
+
"description": "Dispatched after a <code>storage</code> event from another tab for this element's <code>store-name</code> + <code>key-name</code> (or a <code>clear()</code> of that store) has been re-read into <code>provision</code> — so <code>provision</code>, <code>is-success</code> / <code>is-error</code> are already updated when it fires. The read also publishes <code>neutron-provision</code>, as usual.",
|
|
76
|
+
"type": "ProviderStorageChangedEvent",
|
|
77
|
+
"typeExpanded": "CustomEvent & { type: \"provider-storage-changed\"; detail: { keyName: string; oldValue: unknown; newValue: unknown; }; bubbles: true; cancelable: true; composed: true }"
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
"slots": [],
|
|
81
|
+
"cssProperties": [],
|
|
82
|
+
"cssClasses": [],
|
|
83
|
+
"cssAliases": [],
|
|
84
|
+
"listens": [],
|
|
85
|
+
"commands": [],
|
|
86
|
+
"defaultActions": [],
|
|
87
|
+
"expectedChildren": [],
|
|
88
|
+
"provisions": [
|
|
89
|
+
{
|
|
90
|
+
"name": "provision",
|
|
91
|
+
"type": "object",
|
|
92
|
+
"description": "<code>JSON.parse(store.getItem(keyName))</code>, or <code>null</code> if the key is absent or the read failed. Not reflected as an attribute.",
|
|
93
|
+
"fieldName": "provision"
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
"exportedFiles": {}
|
|
99
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`simple view > seeds storage and prints the provision > complexity 1`] = `
|
|
4
|
+
{
|
|
5
|
+
"attributeRuns": 0,
|
|
6
|
+
"closest": 0,
|
|
7
|
+
"getVar": 0,
|
|
8
|
+
"importNode": 0,
|
|
9
|
+
"listenerRuns": 0,
|
|
10
|
+
"matches": 0,
|
|
11
|
+
"parentElement": 0,
|
|
12
|
+
"quarkRuns": 0,
|
|
13
|
+
"queryScopeCost": 0,
|
|
14
|
+
"querySelectorAll": 0,
|
|
15
|
+
"removeAttribute": 0,
|
|
16
|
+
"ruleRuns": 0,
|
|
17
|
+
"schedulePaint": 0,
|
|
18
|
+
"setAttribute": 3,
|
|
19
|
+
"setVar": 0,
|
|
20
|
+
"textContent": 1,
|
|
21
|
+
"variableRuns": 0,
|
|
22
|
+
}
|
|
23
|
+
`;
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
fixture,
|
|
7
|
+
it,
|
|
8
|
+
vi,
|
|
9
|
+
waitForEvent,
|
|
10
|
+
} from "@excom/heft-rig/profiles/default/config/test-utils";
|
|
11
|
+
import "../../index";
|
|
12
|
+
import type { ProviderStorageChangedEvent } from "../../index";
|
|
13
|
+
|
|
14
|
+
const mount = (attrs = "") =>
|
|
15
|
+
fixture<HTMLProviderStorageElement>(
|
|
16
|
+
`<provider-storage ${attrs}></provider-storage>`,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/** Simulate another tab writing `key` in `storageArea`. */
|
|
20
|
+
const otherTabWrites = (
|
|
21
|
+
key: string | null,
|
|
22
|
+
newValue: unknown,
|
|
23
|
+
storageArea: Storage = localStorage,
|
|
24
|
+
) => {
|
|
25
|
+
const oldValue = key === null ? null : storageArea.getItem(key);
|
|
26
|
+
if (key === null) {
|
|
27
|
+
storageArea.clear();
|
|
28
|
+
} else if (newValue === null) {
|
|
29
|
+
storageArea.removeItem(key);
|
|
30
|
+
} else {
|
|
31
|
+
storageArea.setItem(key, JSON.stringify(newValue));
|
|
32
|
+
}
|
|
33
|
+
window.dispatchEvent(
|
|
34
|
+
new StorageEvent("storage", {
|
|
35
|
+
key,
|
|
36
|
+
oldValue,
|
|
37
|
+
newValue: key === null || newValue === null ? null : JSON.stringify(newValue),
|
|
38
|
+
storageArea,
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const flushDisconnect = () => new Promise<void>((r) => queueMicrotask(r));
|
|
44
|
+
|
|
45
|
+
describe("provider-storage", () => {
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
document.body.innerHTML = "";
|
|
48
|
+
vi.unstubAllGlobals();
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
localStorage.clear();
|
|
51
|
+
sessionStorage.clear();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reads data from localStorage", () => {
|
|
55
|
+
const store = new Map<string, string>();
|
|
56
|
+
vi.stubGlobal("localStorage", {
|
|
57
|
+
getItem: (key: string) => store.get(key) ?? null,
|
|
58
|
+
setItem: (key: string, value: string) => store.set(key, value),
|
|
59
|
+
});
|
|
60
|
+
store.set("test-key", JSON.stringify({ ok: true }));
|
|
61
|
+
|
|
62
|
+
const el = mount();
|
|
63
|
+
el.setAttribute("key-name", "test-key");
|
|
64
|
+
|
|
65
|
+
expect(el).dom.to.equalTag(
|
|
66
|
+
`<provider-storage is-success key-name="test-key"></provider-storage>`,
|
|
67
|
+
);
|
|
68
|
+
expect(el.provision).toEqual({ ok: true });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("reading", () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
localStorage.setItem("prefs", JSON.stringify({ theme: "dark" }));
|
|
74
|
+
localStorage.setItem("count", "42");
|
|
75
|
+
localStorage.setItem(
|
|
76
|
+
"nested",
|
|
77
|
+
JSON.stringify({ a: { b: [1, 2, { c: "d" }] } }),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does nothing until key-name is set", () => {
|
|
82
|
+
const el = mount();
|
|
83
|
+
expect(el).dom.to.equalTag(`<provider-storage></provider-storage>`);
|
|
84
|
+
expect(el.provision).toBe(null);
|
|
85
|
+
expect(el.isSuccess).toBeFalsy();
|
|
86
|
+
expect(el.isError).toBeFalsy();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("reads on connect when key-name is present in the markup", () => {
|
|
90
|
+
const el = mount(`key-name="prefs"`);
|
|
91
|
+
expect(el).dom.to.equalTag(
|
|
92
|
+
`<provider-storage is-success key-name="prefs"></provider-storage>`,
|
|
93
|
+
);
|
|
94
|
+
expect(el.provision).toEqual({ theme: "dark" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("parses primitives and nested structures", () => {
|
|
98
|
+
const el = mount(`key-name="count"`);
|
|
99
|
+
expect(el.provision).toBe(42);
|
|
100
|
+
|
|
101
|
+
el.keyName = "nested";
|
|
102
|
+
expect(el.provision).toEqual({ a: { b: [1, 2, { c: "d" }] } });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("reads null for a missing key and still reports success", () => {
|
|
106
|
+
const el = mount(`key-name="does-not-exist"`);
|
|
107
|
+
expect(el).dom.to.equalTag(
|
|
108
|
+
`<provider-storage is-success key-name="does-not-exist"></provider-storage>`,
|
|
109
|
+
);
|
|
110
|
+
expect(el.provision).toBe(null);
|
|
111
|
+
expect(el.isError).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("re-reads when key-name changes", () => {
|
|
115
|
+
const el = mount(`key-name="prefs"`);
|
|
116
|
+
expect(el.provision).toEqual({ theme: "dark" });
|
|
117
|
+
|
|
118
|
+
el.setAttribute("key-name", "count");
|
|
119
|
+
expect(el.provision).toBe(42);
|
|
120
|
+
|
|
121
|
+
el.keyName = "prefs";
|
|
122
|
+
expect(el.provision).toEqual({ theme: "dark" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("re-reads the current value when key-name is toggled off and on", () => {
|
|
126
|
+
const el = mount(`key-name="prefs"`);
|
|
127
|
+
expect(el.provision).toEqual({ theme: "dark" });
|
|
128
|
+
|
|
129
|
+
// a same-tab write is not picked up on its own…
|
|
130
|
+
localStorage.setItem("prefs", JSON.stringify({ theme: "light" }));
|
|
131
|
+
expect(el.provision).toEqual({ theme: "dark" });
|
|
132
|
+
|
|
133
|
+
// …until key-name is re-set (the documented workaround)
|
|
134
|
+
el.keyName = "";
|
|
135
|
+
el.keyName = "prefs";
|
|
136
|
+
expect(el.provision).toEqual({ theme: "light" });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("clears provision and both states when key-name is removed", () => {
|
|
140
|
+
const el = mount(`key-name="prefs"`);
|
|
141
|
+
expect(el.provision).toEqual({ theme: "dark" });
|
|
142
|
+
|
|
143
|
+
el.removeAttribute("key-name");
|
|
144
|
+
|
|
145
|
+
expect(el).dom.to.equalTag(`<provider-storage></provider-storage>`);
|
|
146
|
+
expect(el.provision).toBe(null);
|
|
147
|
+
expect(el.isSuccess).toBe(false);
|
|
148
|
+
expect(el.isError).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("clears is-error too when key-name is removed", () => {
|
|
152
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
153
|
+
localStorage.setItem("broken", "{not json");
|
|
154
|
+
const el = mount(`key-name="broken"`);
|
|
155
|
+
expect(el.isError).toBe(true);
|
|
156
|
+
|
|
157
|
+
el.keyName = null as unknown as string;
|
|
158
|
+
|
|
159
|
+
expect(el).dom.to.equalTag(`<provider-storage></provider-storage>`);
|
|
160
|
+
expect(el.isError).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("never writes to either store", () => {
|
|
164
|
+
const setSpy = vi.spyOn(Storage.prototype, "setItem");
|
|
165
|
+
const removeSpy = vi.spyOn(Storage.prototype, "removeItem");
|
|
166
|
+
const clearSpy = vi.spyOn(Storage.prototype, "clear");
|
|
167
|
+
|
|
168
|
+
const el = mount(`key-name="prefs"`);
|
|
169
|
+
el.keyName = "count";
|
|
170
|
+
el.storeName = "session";
|
|
171
|
+
el.removeAttribute("key-name");
|
|
172
|
+
el.remove();
|
|
173
|
+
|
|
174
|
+
expect(setSpy).not.toHaveBeenCalled();
|
|
175
|
+
expect(removeSpy).not.toHaveBeenCalled();
|
|
176
|
+
expect(clearSpy).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("store-name", () => {
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
localStorage.setItem("prefs", JSON.stringify({ from: "local" }));
|
|
183
|
+
sessionStorage.setItem("prefs", JSON.stringify({ from: "session" }));
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("defaults to localStorage", () => {
|
|
187
|
+
const el = mount(`key-name="prefs"`);
|
|
188
|
+
expect(el.provision).toEqual({ from: "local" });
|
|
189
|
+
expect(el.storeName).toBeFalsy();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("reads sessionStorage with store-name=\"session\"", () => {
|
|
193
|
+
const el = mount(`key-name="prefs" store-name="session"`);
|
|
194
|
+
expect(el).dom.to.equalTag(
|
|
195
|
+
`<provider-storage is-success key-name="prefs" store-name="session"></provider-storage>`,
|
|
196
|
+
);
|
|
197
|
+
expect(el.provision).toEqual({ from: "session" });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("treats an explicit store-name=\"local\" as localStorage", () => {
|
|
201
|
+
const el = mount(`key-name="prefs" store-name="local"`);
|
|
202
|
+
expect(el.provision).toEqual({ from: "local" });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("re-reads from the other store when store-name changes", () => {
|
|
206
|
+
const el = mount(`key-name="prefs"`);
|
|
207
|
+
expect(el.provision).toEqual({ from: "local" });
|
|
208
|
+
|
|
209
|
+
el.setAttribute("store-name", "session");
|
|
210
|
+
expect(el.provision).toEqual({ from: "session" });
|
|
211
|
+
|
|
212
|
+
el.storeName = "local";
|
|
213
|
+
expect(el.provision).toEqual({ from: "local" });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("falls back to localStorage when store-name is removed", () => {
|
|
217
|
+
const el = mount(`key-name="prefs" store-name="session"`);
|
|
218
|
+
expect(el.provision).toEqual({ from: "session" });
|
|
219
|
+
|
|
220
|
+
el.removeAttribute("store-name");
|
|
221
|
+
expect(el.provision).toEqual({ from: "local" });
|
|
222
|
+
expect(el.isSuccess).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("does not read when store-name is set without key-name", () => {
|
|
226
|
+
const el = mount(`store-name="session"`);
|
|
227
|
+
const spy = vi.fn();
|
|
228
|
+
el.addEventListener("neutron-provision", spy);
|
|
229
|
+
|
|
230
|
+
el.storeName = "local";
|
|
231
|
+
|
|
232
|
+
expect(spy).not.toHaveBeenCalled();
|
|
233
|
+
expect(el.isSuccess).toBeFalsy();
|
|
234
|
+
expect(el.provision).toBe(null);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("reads a missing session key as null", () => {
|
|
238
|
+
const el = mount(`key-name="nope" store-name="session"`);
|
|
239
|
+
expect(el.provision).toBe(null);
|
|
240
|
+
expect(el.isSuccess).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("errors", () => {
|
|
245
|
+
it("sets is-error when the stored value is not valid JSON", () => {
|
|
246
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
247
|
+
localStorage.setItem("broken", "{not json");
|
|
248
|
+
|
|
249
|
+
const el = mount(`key-name="broken"`);
|
|
250
|
+
|
|
251
|
+
expect(el).dom.to.equalTag(
|
|
252
|
+
`<provider-storage is-error key-name="broken"></provider-storage>`,
|
|
253
|
+
);
|
|
254
|
+
expect(el.isSuccess).toBe(false);
|
|
255
|
+
expect(el.provision).toBe(null);
|
|
256
|
+
expect(errorSpy).toHaveBeenCalledTimes(1);
|
|
257
|
+
expect(errorSpy.mock.calls[0].join(" ")).toMatch(/provider-storage/);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("nulls the provision when a later read fails", () => {
|
|
261
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
262
|
+
localStorage.setItem("good", JSON.stringify(["a"]));
|
|
263
|
+
localStorage.setItem("bad", "undefined");
|
|
264
|
+
|
|
265
|
+
const el = mount(`key-name="good"`);
|
|
266
|
+
expect(el.provision).toEqual(["a"]);
|
|
267
|
+
|
|
268
|
+
el.keyName = "bad";
|
|
269
|
+
expect(el.isError).toBe(true);
|
|
270
|
+
expect(el.isSuccess).toBe(false);
|
|
271
|
+
expect(el.provision).toBe(null);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("recovers from an error when key-name moves to a valid key", () => {
|
|
275
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
276
|
+
localStorage.setItem("bad", "<html>");
|
|
277
|
+
localStorage.setItem("good", "true");
|
|
278
|
+
|
|
279
|
+
const el = mount(`key-name="bad"`);
|
|
280
|
+
expect(el.isError).toBe(true);
|
|
281
|
+
|
|
282
|
+
el.keyName = "good";
|
|
283
|
+
expect(el).dom.to.equalTag(
|
|
284
|
+
`<provider-storage is-success key-name="good"></provider-storage>`,
|
|
285
|
+
);
|
|
286
|
+
expect(el.provision).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("provision", () => {
|
|
291
|
+
it("publishes every read with neutron-provision", async () => {
|
|
292
|
+
localStorage.setItem("prefs", JSON.stringify({ theme: "dark" }));
|
|
293
|
+
const el = mount();
|
|
294
|
+
|
|
295
|
+
await waitForEvent(el, "neutron-provision", () => {
|
|
296
|
+
el.setAttribute("key-name", "prefs");
|
|
297
|
+
});
|
|
298
|
+
expect(el.provision).toEqual({ theme: "dark" });
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("bubbles neutron-provision to ancestors", () => {
|
|
302
|
+
localStorage.setItem("prefs", "1");
|
|
303
|
+
const el = mount();
|
|
304
|
+
const spy = vi.fn();
|
|
305
|
+
el.parentElement!.addEventListener("neutron-provision", spy);
|
|
306
|
+
|
|
307
|
+
el.keyName = "prefs";
|
|
308
|
+
|
|
309
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
310
|
+
expect((spy.mock.calls[0][0] as Event).target).toBe(el);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe("storage events (other tabs)", () => {
|
|
315
|
+
beforeEach(() => {
|
|
316
|
+
localStorage.setItem("prefs", JSON.stringify({ theme: "dark" }));
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("re-reads and fires provider-storage-changed when another tab writes the key", () => {
|
|
320
|
+
const el = mount(`key-name="prefs"`);
|
|
321
|
+
const seenAtChanged: unknown[] = [];
|
|
322
|
+
const changed = vi.fn(() => seenAtChanged.push(el.provision));
|
|
323
|
+
const provisioned = vi.fn();
|
|
324
|
+
el.addEventListener("provider-storage-changed", changed);
|
|
325
|
+
el.addEventListener("neutron-provision", provisioned);
|
|
326
|
+
|
|
327
|
+
otherTabWrites("prefs", { theme: "light" });
|
|
328
|
+
|
|
329
|
+
expect(el.provision).toEqual({ theme: "light" });
|
|
330
|
+
expect(el.isSuccess).toBe(true);
|
|
331
|
+
expect(changed).toHaveBeenCalledTimes(1);
|
|
332
|
+
const e = changed.mock.calls[0][0] as ProviderStorageChangedEvent;
|
|
333
|
+
expect(e.detail).toEqual({
|
|
334
|
+
keyName: "prefs",
|
|
335
|
+
oldValue: { theme: "dark" },
|
|
336
|
+
newValue: { theme: "light" },
|
|
337
|
+
});
|
|
338
|
+
// provision + state are already applied when the changed event fires…
|
|
339
|
+
expect(seenAtChanged).toEqual([{ theme: "light" }]);
|
|
340
|
+
// …and the read is still published like any other
|
|
341
|
+
expect(provisioned).toHaveBeenCalledTimes(1);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("bubbles provider-storage-changed to ancestors", () => {
|
|
345
|
+
const el = mount(`key-name="prefs"`);
|
|
346
|
+
const spy = vi.fn();
|
|
347
|
+
el.parentElement!.addEventListener("provider-storage-changed", spy);
|
|
348
|
+
|
|
349
|
+
otherTabWrites("prefs", 2);
|
|
350
|
+
|
|
351
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
352
|
+
expect((spy.mock.calls[0][0] as Event).target).toBe(el);
|
|
353
|
+
expect((spy.mock.calls[0][0] as CustomEvent).detail.newValue).toBe(2);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("reports null when another tab removes the key", () => {
|
|
357
|
+
const el = mount(`key-name="prefs"`);
|
|
358
|
+
const spy = vi.fn();
|
|
359
|
+
el.addEventListener("provider-storage-changed", spy);
|
|
360
|
+
|
|
361
|
+
otherTabWrites("prefs", null);
|
|
362
|
+
|
|
363
|
+
expect(el.provision).toBe(null);
|
|
364
|
+
expect(el.isSuccess).toBe(true);
|
|
365
|
+
expect(spy.mock.calls[0][0].detail).toEqual({
|
|
366
|
+
keyName: "prefs",
|
|
367
|
+
oldValue: { theme: "dark" },
|
|
368
|
+
newValue: null,
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("treats a clear() in another tab (key === null) as a change", () => {
|
|
373
|
+
const el = mount(`key-name="prefs"`);
|
|
374
|
+
const spy = vi.fn();
|
|
375
|
+
el.addEventListener("provider-storage-changed", spy);
|
|
376
|
+
|
|
377
|
+
otherTabWrites(null, null);
|
|
378
|
+
|
|
379
|
+
expect(el.provision).toBe(null);
|
|
380
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
381
|
+
expect(spy.mock.calls[0][0].detail.newValue).toBe(null);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("ignores writes to other keys", () => {
|
|
385
|
+
const el = mount(`key-name="prefs"`);
|
|
386
|
+
const changed = vi.fn();
|
|
387
|
+
const provisioned = vi.fn();
|
|
388
|
+
el.addEventListener("provider-storage-changed", changed);
|
|
389
|
+
el.addEventListener("neutron-provision", provisioned);
|
|
390
|
+
|
|
391
|
+
otherTabWrites("other", { x: 1 });
|
|
392
|
+
|
|
393
|
+
expect(changed).not.toHaveBeenCalled();
|
|
394
|
+
expect(provisioned).not.toHaveBeenCalled();
|
|
395
|
+
expect(el.provision).toEqual({ theme: "dark" });
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("ignores writes to the same key in the other store", () => {
|
|
399
|
+
sessionStorage.setItem("prefs", JSON.stringify({ from: "session" }));
|
|
400
|
+
const local = mount(`key-name="prefs"`);
|
|
401
|
+
const session = fixture<HTMLProviderStorageElement>(
|
|
402
|
+
`<provider-storage key-name="prefs" store-name="session"></provider-storage>`,
|
|
403
|
+
);
|
|
404
|
+
const localSpy = vi.fn();
|
|
405
|
+
const sessionSpy = vi.fn();
|
|
406
|
+
local.addEventListener("provider-storage-changed", localSpy);
|
|
407
|
+
session.addEventListener("provider-storage-changed", sessionSpy);
|
|
408
|
+
|
|
409
|
+
otherTabWrites("prefs", { from: "session-2" }, sessionStorage);
|
|
410
|
+
|
|
411
|
+
expect(localSpy).not.toHaveBeenCalled();
|
|
412
|
+
expect(local.provision).toEqual({ theme: "dark" });
|
|
413
|
+
expect(sessionSpy).toHaveBeenCalledTimes(1);
|
|
414
|
+
expect(session.provision).toEqual({ from: "session-2" });
|
|
415
|
+
|
|
416
|
+
otherTabWrites("prefs", { theme: "light" }, localStorage);
|
|
417
|
+
|
|
418
|
+
expect(localSpy).toHaveBeenCalledTimes(1);
|
|
419
|
+
expect(sessionSpy).toHaveBeenCalledTimes(1);
|
|
420
|
+
expect(local.provision).toEqual({ theme: "light" });
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("ignores storage events while key-name is unset", () => {
|
|
424
|
+
const el = mount();
|
|
425
|
+
const spy = vi.fn();
|
|
426
|
+
el.addEventListener("provider-storage-changed", spy);
|
|
427
|
+
|
|
428
|
+
otherTabWrites("prefs", { theme: "light" });
|
|
429
|
+
|
|
430
|
+
expect(spy).not.toHaveBeenCalled();
|
|
431
|
+
expect(el.provision).toBe(null);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("sets is-error and a null provision when the other tab wrote invalid JSON", () => {
|
|
435
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
436
|
+
const el = mount(`key-name="prefs"`);
|
|
437
|
+
const spy = vi.fn();
|
|
438
|
+
el.addEventListener("provider-storage-changed", spy);
|
|
439
|
+
|
|
440
|
+
localStorage.setItem("prefs", "{nope");
|
|
441
|
+
window.dispatchEvent(
|
|
442
|
+
new StorageEvent("storage", {
|
|
443
|
+
key: "prefs",
|
|
444
|
+
oldValue: JSON.stringify({ theme: "dark" }),
|
|
445
|
+
newValue: "{nope",
|
|
446
|
+
storageArea: localStorage,
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
expect(el).dom.to.equalTag(
|
|
451
|
+
`<provider-storage is-error key-name="prefs"></provider-storage>`,
|
|
452
|
+
);
|
|
453
|
+
expect(el.provision).toBe(null);
|
|
454
|
+
expect(spy.mock.calls[0][0].detail).toEqual({
|
|
455
|
+
keyName: "prefs",
|
|
456
|
+
oldValue: { theme: "dark" },
|
|
457
|
+
newValue: null,
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("listens on window once per element while connected", () => {
|
|
462
|
+
const addSpy = vi.spyOn(window, "addEventListener");
|
|
463
|
+
mount(`key-name="prefs"`);
|
|
464
|
+
expect(
|
|
465
|
+
addSpy.mock.calls.filter(([type]) => type === "storage"),
|
|
466
|
+
).toHaveLength(1);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("stops listening once disconnected", async () => {
|
|
470
|
+
const removeSpy = vi.spyOn(window, "removeEventListener");
|
|
471
|
+
const el = mount(`key-name="prefs"`);
|
|
472
|
+
const spy = vi.fn();
|
|
473
|
+
el.addEventListener("provider-storage-changed", spy);
|
|
474
|
+
|
|
475
|
+
el.remove();
|
|
476
|
+
await flushDisconnect();
|
|
477
|
+
|
|
478
|
+
expect(
|
|
479
|
+
removeSpy.mock.calls.filter(([type]) => type === "storage"),
|
|
480
|
+
).toHaveLength(1);
|
|
481
|
+
|
|
482
|
+
otherTabWrites("prefs", { theme: "light" });
|
|
483
|
+
expect(spy).not.toHaveBeenCalled();
|
|
484
|
+
expect(el.provision).toEqual({ theme: "dark" });
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("keeps listening across a synchronous move", async () => {
|
|
488
|
+
const el = mount(`key-name="prefs"`);
|
|
489
|
+
const spy = vi.fn();
|
|
490
|
+
el.addEventListener("provider-storage-changed", spy);
|
|
491
|
+
const newParent = document.createElement("section");
|
|
492
|
+
document.body.append(newParent);
|
|
493
|
+
|
|
494
|
+
newParent.append(el); // disconnect + connect in the same tick
|
|
495
|
+
await flushDisconnect();
|
|
496
|
+
|
|
497
|
+
otherTabWrites("prefs", { theme: "light" });
|
|
498
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
499
|
+
expect(el.provision).toEqual({ theme: "light" });
|
|
500
|
+
|
|
501
|
+
// and only once, no duplicate listener after the move
|
|
502
|
+
otherTabWrites("prefs", { theme: "dark" });
|
|
503
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
it("listens again after a later re-insert", async () => {
|
|
507
|
+
const el = mount(`key-name="prefs"`);
|
|
508
|
+
const spy = vi.fn();
|
|
509
|
+
el.addEventListener("provider-storage-changed", spy);
|
|
510
|
+
|
|
511
|
+
el.remove();
|
|
512
|
+
await flushDisconnect();
|
|
513
|
+
document.body.append(el);
|
|
514
|
+
|
|
515
|
+
otherTabWrites("prefs", { theme: "light" });
|
|
516
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
517
|
+
expect(el.provision).toEqual({ theme: "light" });
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
});
|