@tiaforge-ui/sinter 0.3.0 → 0.3.1
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/dist/cache.js +9 -2
- package/dist/check/shadow.js +1 -1
- package/dist/compiler/compile.js +9 -3
- package/dist/compiler/diagnostic-docs.js +5 -5
- package/dist/compiler/expr.js +22 -0
- package/dist/ir.js +21 -3
- package/dist/paginate.js +11 -4
- package/dist/persist.js +4 -1
- package/dist/runtime/app.d.ts +2 -0
- package/dist/runtime/app.js +29 -25
- package/dist/runtime/helpers.js +1 -1
- package/dist/runtime/request.js +4 -2
- package/dist/runtime/store.js +2 -1
- package/dist/sse.js +6 -3
- package/docs/diagnostics.md +5 -5
- package/package.json +1 -1
package/dist/cache.js
CHANGED
|
@@ -68,22 +68,29 @@ const cache = {
|
|
|
68
68
|
loaded(def, r, url, items, total, appended) {
|
|
69
69
|
let t = tracked.get(def.name);
|
|
70
70
|
if (!t) {
|
|
71
|
-
const entry = (t = { def, r, url, dirty: false, appended });
|
|
71
|
+
const entry = (t = { def, r, url, dirty: false, appended, off: false });
|
|
72
72
|
// confirmed changes after the load (server events, action results); layers never fire this
|
|
73
73
|
r.subscribe(() => { entry.dirty = true; });
|
|
74
74
|
tracked.set(def.name, t);
|
|
75
75
|
}
|
|
76
76
|
t.url = url;
|
|
77
77
|
t.appended = appended;
|
|
78
|
+
t.off = false;
|
|
78
79
|
// `append` caches only its first page: later pages would be shown as if they were it
|
|
79
80
|
if (!appended)
|
|
80
81
|
write(def, url, items, total);
|
|
81
82
|
t.dirty = false;
|
|
82
83
|
},
|
|
84
|
+
// the emptied resource must not replace the entry of its last URL
|
|
85
|
+
cleared(def) {
|
|
86
|
+
const t = tracked.get(def.name);
|
|
87
|
+
if (t)
|
|
88
|
+
t.off = true;
|
|
89
|
+
},
|
|
83
90
|
};
|
|
84
91
|
app.addCleanup(app.env.listen('pagehide', () => {
|
|
85
92
|
for (const t of tracked.values()) {
|
|
86
|
-
if (!t.dirty || t.appended || t.def.paginate?.mode == 'append')
|
|
93
|
+
if (!t.dirty || t.off || t.appended || t.def.paginate?.mode == 'append')
|
|
87
94
|
continue;
|
|
88
95
|
const b = t.r.base();
|
|
89
96
|
write(t.def, t.url, b.items, b.total);
|
package/dist/check/shadow.js
CHANGED
|
@@ -103,7 +103,7 @@ export function shadow(o) {
|
|
|
103
103
|
line('/** @type {string} */');
|
|
104
104
|
line('let $locale = $any;');
|
|
105
105
|
line(`/** @type {${decl('Store')}<{ ${types.join('; ')} }>} */`);
|
|
106
|
-
line(`const { ${STORE_FNS.join(', ')} } = $any;`);
|
|
106
|
+
line(`const { ${STORE_FNS.filter((f) => !unit.resources.some((r) => r.name === f)).join(', ')} } = $any;`);
|
|
107
107
|
line(`/** @template T @param {T} list @returns {${decl('Item')}<T>} */`);
|
|
108
108
|
line('function $item(list) { return $any; }');
|
|
109
109
|
/** a JSDoc tag with a type; a type the app wrote maps its errors to where it was written */
|
package/dist/compiler/compile.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import * as parse5 from 'parse5';
|
|
4
4
|
import { parseExpressionAt } from 'acorn';
|
|
5
5
|
import { didYouMean, severityOf } from './diagnostics.js';
|
|
6
|
-
import { analyze } from './expr.js';
|
|
6
|
+
import { analyze, STORE_FNS } from './expr.js';
|
|
7
7
|
import { ATTRIBUTES, ELEMENTS } from './vocabulary.js';
|
|
8
8
|
import { matchOperation, matchResource, queryNames, walk } from './openapi.js';
|
|
9
9
|
const ATTRS = ATTRIBUTES.map((a) => a.name);
|
|
@@ -340,6 +340,10 @@ class Compiler {
|
|
|
340
340
|
this.report(child, 'name', 'SN302', `resource \`${name}\` is declared twice`, 0);
|
|
341
341
|
continue;
|
|
342
342
|
}
|
|
343
|
+
if (STORE_FNS.includes(name)) {
|
|
344
|
+
this.report(child, 'name', 'SN209', `\`${name}\` is a store function (\`${name}()\`); a resource cannot take its name`, 0);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
343
347
|
const key = attr(child, 'key');
|
|
344
348
|
const paginate = attr(child, 'paginate');
|
|
345
349
|
const info = { name, key: key ? key.split(',').map((s) => s.trim()).filter(Boolean) : null, paginate: paginate ?? null, used: false };
|
|
@@ -1006,8 +1010,10 @@ class Compiler {
|
|
|
1006
1010
|
if (lit)
|
|
1007
1011
|
parts.push(lit);
|
|
1008
1012
|
const first = parts[0];
|
|
1009
|
-
|
|
1010
|
-
|
|
1013
|
+
// a literal origin followed by a value needs its `/`: `https://api.example.com{x}` lets `x` extend the host
|
|
1014
|
+
const origin = parts.length > 1 ? /^https?:\/\/[^/{}]+\//i : /^https?:\/\/[^/{}]+(\/|$)/i;
|
|
1015
|
+
if (typeof first !== 'string' || !(/^\/[^/\\]/.test(first) || first === '/' || origin.test(first))) {
|
|
1016
|
+
this.report(el, attrName, 'SN207', 'a URL template must start with a literal same-origin path (`/api/…`) or a literal `https://` origin (with a `/` after the host when a value follows)', 0, 'a leading interpolation or `//` lets a value choose the host');
|
|
1011
1017
|
}
|
|
1012
1018
|
return parts;
|
|
1013
1019
|
}
|
|
@@ -41,7 +41,7 @@ export const DIAGNOSTIC_DOCS = {
|
|
|
41
41
|
},
|
|
42
42
|
SN205: {
|
|
43
43
|
example: '`m.is_read = true` mutates a resource. Use `patch(\'messages\', m, {is_read: true})`',
|
|
44
|
-
means: 'Resource data is read-only in expressions: it mirrors the server, and a direct write would be lost on the next refresh or event.',
|
|
44
|
+
means: 'Resource data is read-only in expressions: it mirrors the server, and a direct write (an assignment, a mutating method such as `push()`, or `Object.assign()` on an item) would be lost on the next refresh or event.',
|
|
45
45
|
fix: 'Use the `patch()` call from the message, or copy the item with `structuredClone(c)` to edit it in state.',
|
|
46
46
|
},
|
|
47
47
|
SN206: {
|
|
@@ -51,8 +51,8 @@ export const DIAGNOSTIC_DOCS = {
|
|
|
51
51
|
},
|
|
52
52
|
SN207: {
|
|
53
53
|
example: '`onclick` cannot be bound with `data-sn-attr`; use `data-sn-on-click`',
|
|
54
|
-
means: 'The binding could run script or inject markup from data: event-handler attributes, `style`, `srcdoc`, `innerHTML`, bindings on `<script>` and similar elements, URL templates that do not start with a literal path
|
|
55
|
-
fix: 'Use the safe equivalent the message names: `data-sn-on-*` for events, `data-sn-style` for styles, a literal `/path` at the start of a URL template.',
|
|
54
|
+
means: 'The binding could run script or inject markup from data: event-handler attributes, `style`, `srcdoc`, `innerHTML`, bindings on `<script>` and similar elements, URL templates that do not start with a literal path (or with a literal origin that lacks its `/` before a value), persisted password fields, or `constructor`, `__proto__` and `prototype` named in an expression.',
|
|
55
|
+
fix: 'Use the safe equivalent the message names: `data-sn-on-*` for events, `data-sn-style` for styles, a literal `/path` (or `https://host/`) at the start of a URL template.',
|
|
56
56
|
},
|
|
57
57
|
SN208: {
|
|
58
58
|
example: 'Property \'nmae\' does not exist on type \'Contact\'. (TS2339)',
|
|
@@ -61,8 +61,8 @@ export const DIAGNOSTIC_DOCS = {
|
|
|
61
61
|
},
|
|
62
62
|
SN209: {
|
|
63
63
|
example: '`s` is already defined here by `data-sn-let`; use another name',
|
|
64
|
-
means: 'A `data-sn-let` name is inlined wherever it is read, so it cannot share a name with a state variable or loop variable in its scope, or be redeclared inside it.',
|
|
65
|
-
fix: 'Rename the let,
|
|
64
|
+
means: 'A `data-sn-let` name is inlined wherever it is read, so it cannot share a name with a state variable or loop variable in its scope, or be redeclared inside it. A resource cannot take the name of a store function (`refresh`, `upsert`, `patch`, `remove`, `page`, `showModal`, `close`, `reset`, `emit`).',
|
|
65
|
+
fix: 'Rename the let, the state or loop variable, or the resource.',
|
|
66
66
|
},
|
|
67
67
|
SN301: {
|
|
68
68
|
example: 'resource `mesages` is not declared on this page',
|
package/dist/compiler/expr.js
CHANGED
|
@@ -40,6 +40,8 @@ const READ_ONLY = new Set([
|
|
|
40
40
|
'getMinutes', 'getSeconds', 'test', 'exec',
|
|
41
41
|
]);
|
|
42
42
|
const MUTATORS = new Set(['push', 'pop', 'splice', 'sort', 'reverse', 'shift', 'unshift', 'fill', 'copyWithin', 'add', 'delete', 'clear', 'set']);
|
|
43
|
+
/** `Object.<static>(target, …)` calls that change their first argument */
|
|
44
|
+
const OBJECT_MUTATORS = new Set(['assign', 'defineProperty', 'defineProperties', 'setPrototypeOf']);
|
|
43
45
|
/** callbacks of these methods receive elements of the receiver as first parameter */
|
|
44
46
|
const ELEMENT_CALLBACKS = new Set(['forEach', 'map', 'filter', 'find', 'findIndex', 'findLast', 'findLastIndex', 'some', 'every', 'flatMap', 'toSorted', 'sort']);
|
|
45
47
|
export function analyze(src, scope, page, opts) {
|
|
@@ -548,6 +550,26 @@ export function analyze(src, scope, page, opts) {
|
|
|
548
550
|
}
|
|
549
551
|
}
|
|
550
552
|
}
|
|
553
|
+
// `Object.assign(target, …)` and friends write to their first argument like `target.x = …` would;
|
|
554
|
+
// a forbidden key named literally in an `Object.*` call reaches what `.constructor` is refused for
|
|
555
|
+
if (m.object.type === 'Identifier' && m.object.name === 'Object' && !locals.has('Object') && method) {
|
|
556
|
+
for (const a of n.arguments) {
|
|
557
|
+
if (a.type === 'Literal' && FORBIDDEN_KEYS.has(String(a.value)))
|
|
558
|
+
report('SN207', `\`${a.raw}\` is not allowed as an argument of \`Object.${method}()\``, a.start);
|
|
559
|
+
}
|
|
560
|
+
const target = n.arguments[0];
|
|
561
|
+
const cp = OBJECT_MUTATORS.has(method) && target && (target.type === 'Identifier' || target.type === 'MemberExpression') ? chainPath(target, locals) : null;
|
|
562
|
+
if (cp) {
|
|
563
|
+
if (cp.resource)
|
|
564
|
+
report('SN205', `\`Object.${method}(${src.slice(target.start, target.end)}, …)\` mutates a resource. Use \`patch('${cp.resource}', …)\` or copy with structuredClone()`, n.start);
|
|
565
|
+
else if (cp.segs && cp.res.kind === 'state') {
|
|
566
|
+
if (mode === 'value')
|
|
567
|
+
report('SN203', `value expressions cannot change state; move \`Object.${method}()\` to \`data-sn-on-*\``, n.start);
|
|
568
|
+
else if (mode === 'handler')
|
|
569
|
+
writes.add(track(cp, cp.segs, n).join('.'));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
551
573
|
// time and randomness
|
|
552
574
|
if (mode === 'value' && m.object.type === 'Identifier' && !m.computed) {
|
|
553
575
|
const o = m.object.name, p = m.property.name;
|
package/dist/ir.js
CHANGED
|
@@ -35,7 +35,14 @@ export function validateIR(ir, fnCount) {
|
|
|
35
35
|
if (names.has(r.name))
|
|
36
36
|
errs.push(`${at}: duplicate name ${r.name}`);
|
|
37
37
|
names.add(r.name);
|
|
38
|
-
|
|
38
|
+
if (r.call) {
|
|
39
|
+
fn(r.call.args, `${at}.call.args`, false);
|
|
40
|
+
fn(r.call.run, `${at}.call.run`, false);
|
|
41
|
+
if (r.src.length)
|
|
42
|
+
errs.push(`${at}: a call resource has no src`);
|
|
43
|
+
}
|
|
44
|
+
else
|
|
45
|
+
url(r.src, `${at}.src`);
|
|
39
46
|
fn(r.when, `${at}.when`);
|
|
40
47
|
if (r.paginate) {
|
|
41
48
|
if (!['keyset', 'offset', 'append'].includes(r.paginate.mode))
|
|
@@ -109,11 +116,22 @@ export function validateIR(ir, fnCount) {
|
|
|
109
116
|
});
|
|
110
117
|
o.actions.forEach((a, i) => {
|
|
111
118
|
const at = `actions[${i}]`;
|
|
112
|
-
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(a.method))
|
|
119
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'CALL'].includes(a.method))
|
|
113
120
|
errs.push(`${at}: bad method`);
|
|
114
121
|
if (!['block', 'queue', 'latest'].includes(a.concurrency))
|
|
115
122
|
errs.push(`${at}: bad concurrency`);
|
|
116
|
-
|
|
123
|
+
if (a.method === 'CALL') {
|
|
124
|
+
if (!a.call)
|
|
125
|
+
errs.push(`${at}: CALL needs call`);
|
|
126
|
+
else {
|
|
127
|
+
fn(a.call.args, `${at}.call.args`, false);
|
|
128
|
+
fn(a.call.run, `${at}.call.run`, false);
|
|
129
|
+
}
|
|
130
|
+
if (a.url.length)
|
|
131
|
+
errs.push(`${at}: a call action has no url`);
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
url(a.url, `${at}.url`);
|
|
117
135
|
for (const k of ['body', 'optimistic', 'then', 'catch'])
|
|
118
136
|
fn(a[k], `${at}.${k}`);
|
|
119
137
|
});
|
package/dist/paginate.js
CHANGED
|
@@ -64,10 +64,16 @@ class PagerImpl {
|
|
|
64
64
|
n = target;
|
|
65
65
|
else if (target === 'first')
|
|
66
66
|
n = 1;
|
|
67
|
-
else if (target === 'prev')
|
|
67
|
+
else if (target === 'prev') {
|
|
68
|
+
if (page <= 1)
|
|
69
|
+
return;
|
|
68
70
|
n = page - 1;
|
|
69
|
-
|
|
71
|
+
}
|
|
72
|
+
else if (target === 'next') {
|
|
73
|
+
if (meta.pages != null && page >= meta.pages)
|
|
74
|
+
return;
|
|
70
75
|
n = page + 1;
|
|
76
|
+
}
|
|
71
77
|
else if (target === 'last') {
|
|
72
78
|
if (meta.pages == null) {
|
|
73
79
|
this.loader.app.warn(`page('${this.loader.def.name}', 'last') needs a known total`);
|
|
@@ -141,8 +147,9 @@ class PagerImpl {
|
|
|
141
147
|
hasNext = full;
|
|
142
148
|
break;
|
|
143
149
|
}
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
// past the end: nothing to count (`to` would come before `from`)
|
|
151
|
+
from = n && move.to != null ? move.to + 1 : n ? null : 0;
|
|
152
|
+
to = n ? (from != null ? from + n - 1 : null) : 0;
|
|
146
153
|
hasPrev = true;
|
|
147
154
|
hasNext = full;
|
|
148
155
|
break;
|
package/dist/persist.js
CHANGED
|
@@ -47,9 +47,12 @@ const persist = {
|
|
|
47
47
|
};
|
|
48
48
|
function overlaps(a, b) {
|
|
49
49
|
const n = Math.min(a.length, b.length);
|
|
50
|
-
for (let i = 0; i < n; i++)
|
|
50
|
+
for (let i = 0; i < n; i++) {
|
|
51
|
+
if (b[i] === '**')
|
|
52
|
+
return true;
|
|
51
53
|
if (a[i] !== b[i] && a[i] !== '*' && b[i] !== '*')
|
|
52
54
|
return false;
|
|
55
|
+
}
|
|
53
56
|
return true;
|
|
54
57
|
}
|
|
55
58
|
export default persist;
|
package/dist/runtime/app.d.ts
CHANGED
|
@@ -112,6 +112,8 @@ export interface App {
|
|
|
112
112
|
} | null;
|
|
113
113
|
/** a load (or embedded data) stored `items`: cache them, and the resource's later changes */
|
|
114
114
|
loaded(def: ResourceDef, r: Resource, url: string, items: unknown, total: number | null, appended: boolean): void;
|
|
115
|
+
/** the resource was emptied (`when` is false): nothing to write until the next load */
|
|
116
|
+
cleared(def: ResourceDef): void;
|
|
115
117
|
};
|
|
116
118
|
};
|
|
117
119
|
readonly beforeFlush: Array<(dirty: string[][]) => void>;
|
package/dist/runtime/app.js
CHANGED
|
@@ -58,7 +58,7 @@ export function mount(root, o) {
|
|
|
58
58
|
/** `showModal(id)`, `close(id)`: the dialog of this mount root with that id */
|
|
59
59
|
const dialog = (id) => [...root.querySelectorAll('dialog')].find((d) => d.id === id);
|
|
60
60
|
const rootRbs = [], rootScopes = [], found = new Map();
|
|
61
|
-
let dirty = [], cur = [], scheduled = false, flushing = false, round = 0;
|
|
61
|
+
let dirty = [], cur = [], scheduled = false, flushing = false, round = 0, destroyed = false;
|
|
62
62
|
const res = (name) => {
|
|
63
63
|
const r = resources.get(name);
|
|
64
64
|
if (!r)
|
|
@@ -105,16 +105,19 @@ export function mount(root, o) {
|
|
|
105
105
|
* absent) and dirty path `d` are prefixes of each other; `*` matches any segment. A change
|
|
106
106
|
* that ends above a row's own element, or only changes the list structure (`#`), does not
|
|
107
107
|
* reach it: reconcile re-evaluates rows whose item or index changed. `~` reaches only rows
|
|
108
|
-
* whose element was marked.
|
|
108
|
+
* whose element was marked. A path ending in `**` (an island's `update()`) reaches everything
|
|
109
|
+
* under its prefix, at any depth.
|
|
109
110
|
*/
|
|
110
111
|
const overlaps = (dep, d, s) => reach(dep, d, s) > 0;
|
|
111
112
|
/** 0: no overlap; 1: only through a marked element (`~`) of the row; 2: overlap. */
|
|
112
113
|
function reach({ p, n, a, t }, d, s) {
|
|
113
|
-
if (a >= 0 && d.length <= a)
|
|
114
|
+
if (a >= 0 && d.length <= a && d[d.length - 1] != '**')
|
|
114
115
|
return 0;
|
|
115
116
|
let via = 2;
|
|
116
117
|
for (let i = 0, len = Math.min(p.length, d.length); i < len; i++) {
|
|
117
118
|
const y = d[i];
|
|
119
|
+
if (y == '**')
|
|
120
|
+
return 2;
|
|
118
121
|
// a list's structure dependency: reached by changes to the list itself or a whole element, not element fields
|
|
119
122
|
if (p[i] == '#')
|
|
120
123
|
return d.length <= i + 1 ? 2 : 0;
|
|
@@ -158,7 +161,14 @@ export function mount(root, o) {
|
|
|
158
161
|
$r: {}, $meta: {}, $url: readUrl(), $now: new Date(env.now()),
|
|
159
162
|
$m: (el, v) => (marked.add(el), v),
|
|
160
163
|
get $locale() { return config.locale?.get(); },
|
|
161
|
-
set $locale(v) {
|
|
164
|
+
set $locale(v) {
|
|
165
|
+
config.locale?.set(v);
|
|
166
|
+
app.mark('$locale');
|
|
167
|
+
// ordered lists sort strings by locale
|
|
168
|
+
for (const def of ir.resources)
|
|
169
|
+
if (def.order)
|
|
170
|
+
app.mark('$r.' + def.name);
|
|
171
|
+
},
|
|
162
172
|
},
|
|
163
173
|
resource: res,
|
|
164
174
|
request: (url, init) => req.send(url, init),
|
|
@@ -182,12 +192,14 @@ export function mount(root, o) {
|
|
|
182
192
|
state(el) {
|
|
183
193
|
for (let e = el; e; e = e.parentElement) {
|
|
184
194
|
const s = e[STATE];
|
|
195
|
+
// `**`: the island may have changed anything under the state, rows included
|
|
185
196
|
if (s)
|
|
186
|
-
return { get: () => s[1], update: (fn) => { fn(s[1]); app.mark('$s' + s[0]); } };
|
|
197
|
+
return { get: () => s[1], update: (fn) => { fn(s[1]); app.mark('$s' + s[0] + '.**'); } };
|
|
187
198
|
}
|
|
188
199
|
throw Error('sinter: no data-sn-state scope');
|
|
189
200
|
},
|
|
190
201
|
destroy() {
|
|
202
|
+
destroyed = true;
|
|
191
203
|
for (const c of cleanups.splice(0))
|
|
192
204
|
c();
|
|
193
205
|
instances.delete(root);
|
|
@@ -304,7 +316,8 @@ export function mount(root, o) {
|
|
|
304
316
|
console.error(e);
|
|
305
317
|
}
|
|
306
318
|
},
|
|
307
|
-
|
|
319
|
+
// after destroy(), nothing waits for the next one: run it now
|
|
320
|
+
addCleanup: (fn) => void (destroyed ? fn() : cleanups.push(fn)),
|
|
308
321
|
};
|
|
309
322
|
app.page.$f = app.storeFns(null);
|
|
310
323
|
instances.set(root, app);
|
|
@@ -901,7 +914,9 @@ export function mount(root, o) {
|
|
|
901
914
|
app.busy--;
|
|
902
915
|
}
|
|
903
916
|
app.busy++;
|
|
904
|
-
|
|
917
|
+
// the element may have left the page (its row removed) while the handler waited
|
|
918
|
+
timers.set(rb, env.setTimeout(() => { timers.delete(rb); app.busy--; if (rb.el.isConnected)
|
|
919
|
+
handle(rb, rb.b.f, e, rb.b.w); }, rb.b.ms, 'debounce'));
|
|
905
920
|
}
|
|
906
921
|
// ---- actions
|
|
907
922
|
function trigger(rb, e) {
|
|
@@ -936,7 +951,8 @@ export function mount(root, o) {
|
|
|
936
951
|
else {
|
|
937
952
|
url = app.url(a.url, s);
|
|
938
953
|
const fields = a.form && !get && a.method != 'DELETE';
|
|
939
|
-
|
|
954
|
+
// form encoding: the fields as the browser would submit them (FormData), then as one of its bodies
|
|
955
|
+
body = a.body != null ? fns[a.body](s) : fields ? (a.enc == 'form' ? new FormData(rb.el) : serialize(rb.el)) : undefined;
|
|
940
956
|
if (a.enc == 'form')
|
|
941
957
|
body = formBody(body);
|
|
942
958
|
}
|
|
@@ -1151,6 +1167,8 @@ export function mount(root, o) {
|
|
|
1151
1167
|
return void l.load();
|
|
1152
1168
|
c?.abort();
|
|
1153
1169
|
r.replace(r.isCollection ? [] : null);
|
|
1170
|
+
if (def.cache != null)
|
|
1171
|
+
app.ext.cache?.cleared(def);
|
|
1154
1172
|
loading(false);
|
|
1155
1173
|
},
|
|
1156
1174
|
/** Repeats the current request; several calls in one task share one request. */
|
|
@@ -1228,7 +1246,7 @@ export function mount(root, o) {
|
|
|
1228
1246
|
export function withParams(url, params) {
|
|
1229
1247
|
if (!params)
|
|
1230
1248
|
return url;
|
|
1231
|
-
const [path, hash] = url.split('#'),
|
|
1249
|
+
const [path, hash] = url.split('#'), i = path.indexOf('?'), base = i < 0 ? path : path.slice(0, i), q = new URLSearchParams(i < 0 ? '' : path.slice(i + 1));
|
|
1232
1250
|
for (const k in params)
|
|
1233
1251
|
q.set(k, params[k]);
|
|
1234
1252
|
return `${base}?${q}${hash ? '#' + hash : ''}`;
|
|
@@ -1279,21 +1297,6 @@ export function formBody(body) {
|
|
|
1279
1297
|
}
|
|
1280
1298
|
return out;
|
|
1281
1299
|
}
|
|
1282
|
-
/** A form's fields as browsers submit them; with a file input, multipart. */
|
|
1283
|
-
function formFields(form) {
|
|
1284
|
-
const out = new URLSearchParams();
|
|
1285
|
-
for (const c of form.elements) {
|
|
1286
|
-
if (!c.name || c.disabled || /^(submit|button|reset|image|fieldset|output)$/.test(c.type))
|
|
1287
|
-
continue;
|
|
1288
|
-
if (c.type == 'file')
|
|
1289
|
-
return new FormData(form);
|
|
1290
|
-
if ((c.type == 'checkbox' || c.type == 'radio') && !c.checked)
|
|
1291
|
-
continue;
|
|
1292
|
-
for (const v of c.type == 'select-multiple' ? [...c.selectedOptions].map((o) => o.value) : [c.value])
|
|
1293
|
-
out.append(c.name, v);
|
|
1294
|
-
}
|
|
1295
|
-
return out;
|
|
1296
|
-
}
|
|
1297
1300
|
export function serialize(form) {
|
|
1298
1301
|
const controls = [...form.elements];
|
|
1299
1302
|
if (controls.some((c) => c.type == 'file'))
|
|
@@ -1308,7 +1311,8 @@ export function serialize(form) {
|
|
|
1308
1311
|
continue;
|
|
1309
1312
|
const k = segs.pop();
|
|
1310
1313
|
let o = out;
|
|
1311
|
-
|
|
1314
|
+
// a container at each step; a scalar written there earlier (`a`, then `a.b`) is replaced
|
|
1315
|
+
segs.forEach((s, i) => (o = typeof o[s] == 'object' && o[s] ? o[s] : (o[s] = /^\d+$/.test(segs[i + 1] ?? k) ? [] : Object.create(null))));
|
|
1312
1316
|
if (type == 'checkbox' && boxes[name] > 1)
|
|
1313
1317
|
(o[k] ??= []).push(...(c.checked ? [c.value] : []));
|
|
1314
1318
|
else
|
package/dist/runtime/helpers.js
CHANGED
|
@@ -44,7 +44,7 @@ export function number(v, opts) {
|
|
|
44
44
|
const BYTE_UNITS = ['byte', 'kilobyte', 'megabyte', 'gigabyte', 'terabyte'];
|
|
45
45
|
export function bytes(v) {
|
|
46
46
|
let n = Number(v);
|
|
47
|
-
if (v == null || Number.isNaN(n))
|
|
47
|
+
if (v == null || v === '' || Number.isNaN(n))
|
|
48
48
|
return '';
|
|
49
49
|
let i = 0;
|
|
50
50
|
while (Math.abs(n) >= 1024 && i < BYTE_UNITS.length - 1) {
|
package/dist/runtime/request.js
CHANGED
|
@@ -54,8 +54,8 @@ export function createRequestLayer(env, chain) {
|
|
|
54
54
|
}
|
|
55
55
|
headers.has('Accept') || headers.set('Accept', 'application/json');
|
|
56
56
|
// mutations outlive the page: a navigation right after a click must not cancel the write
|
|
57
|
-
// (keepalive bodies are limited to 64
|
|
58
|
-
const keepalive = method != 'GET' && method != 'HEAD' && (body == null || (typeof body == 'string' && body
|
|
57
|
+
// (keepalive bodies are limited to 64 KiB; larger ones and uploads go without it)
|
|
58
|
+
const keepalive = method != 'GET' && method != 'HEAD' && (body == null || (typeof body == 'string' && bytes(body) < 60_000));
|
|
59
59
|
inflight++;
|
|
60
60
|
try {
|
|
61
61
|
const res = await run(new Request(target, { method, headers, body: body ?? null, credentials: 'same-origin', signal: init.signal ?? null, keepalive }));
|
|
@@ -82,6 +82,8 @@ export function createRequestLayer(env, chain) {
|
|
|
82
82
|
}
|
|
83
83
|
return { send, raw, get inflight() { return inflight; } };
|
|
84
84
|
}
|
|
85
|
+
/** UTF-8 size of a string; a short one cannot reach the limit (at most 3 bytes per UTF-16 unit). */
|
|
86
|
+
const bytes = (s) => (s.length < 20_000 ? s.length : new TextEncoder().encode(s).length);
|
|
85
87
|
/** 204 or empty → null; JSON content type → parsed; anything else → text. */
|
|
86
88
|
export async function parseBody(res) {
|
|
87
89
|
const text = res.status == 204 ? '' : await res.text();
|
package/dist/runtime/store.js
CHANGED
|
@@ -160,7 +160,8 @@ export function createResource(name, o) {
|
|
|
160
160
|
}
|
|
161
161
|
};
|
|
162
162
|
function visible() {
|
|
163
|
-
|
|
163
|
+
// a locale change re-sorts strings: a view sorted for another locale is stale
|
|
164
|
+
if (view && !(coll && field && o.locale?.() !== sortedFor))
|
|
164
165
|
return view;
|
|
165
166
|
if (!coll)
|
|
166
167
|
return (view = { l: [], get: () => undefined, s: over ? over.s : single });
|
package/dist/sse.js
CHANGED
|
@@ -199,10 +199,13 @@ class Connection {
|
|
|
199
199
|
const sse = {
|
|
200
200
|
name: 'sse',
|
|
201
201
|
install(app) {
|
|
202
|
-
// connect after mount has created resources and loaders
|
|
202
|
+
// connect after mount has created resources and loaders, unless the app is gone by then
|
|
203
|
+
let alive = true;
|
|
204
|
+
app.addCleanup(() => { alive = false; });
|
|
203
205
|
app.env.queueMicrotask(() => {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
+
if (alive)
|
|
207
|
+
for (const def of app.ir.syncs)
|
|
208
|
+
new Connection(app, def).start();
|
|
206
209
|
});
|
|
207
210
|
},
|
|
208
211
|
};
|
package/docs/diagnostics.md
CHANGED
|
@@ -88,7 +88,7 @@ Error, for example:
|
|
|
88
88
|
|
|
89
89
|
> `m.is_read = true` mutates a resource. Use `patch('messages', m, {is_read: true})`
|
|
90
90
|
|
|
91
|
-
Resource data is read-only in expressions: it mirrors the server, and a direct write would be lost on the next refresh or event.
|
|
91
|
+
Resource data is read-only in expressions: it mirrors the server, and a direct write (an assignment, a mutating method such as `push()`, or `Object.assign()` on an item) would be lost on the next refresh or event.
|
|
92
92
|
|
|
93
93
|
**Fix:** Use the `patch()` call from the message, or copy the item with `structuredClone(c)` to edit it in state.
|
|
94
94
|
|
|
@@ -108,9 +108,9 @@ Error, for example:
|
|
|
108
108
|
|
|
109
109
|
> `onclick` cannot be bound with `data-sn-attr`; use `data-sn-on-click`
|
|
110
110
|
|
|
111
|
-
The binding could run script or inject markup from data: event-handler attributes, `style`, `srcdoc`, `innerHTML`, bindings on `<script>` and similar elements, URL templates that do not start with a literal path
|
|
111
|
+
The binding could run script or inject markup from data: event-handler attributes, `style`, `srcdoc`, `innerHTML`, bindings on `<script>` and similar elements, URL templates that do not start with a literal path (or with a literal origin that lacks its `/` before a value), persisted password fields, or `constructor`, `__proto__` and `prototype` named in an expression.
|
|
112
112
|
|
|
113
|
-
**Fix:** Use the safe equivalent the message names: `data-sn-on-*` for events, `data-sn-style` for styles, a literal `/path` at the start of a URL template.
|
|
113
|
+
**Fix:** Use the safe equivalent the message names: `data-sn-on-*` for events, `data-sn-style` for styles, a literal `/path` (or `https://host/`) at the start of a URL template.
|
|
114
114
|
|
|
115
115
|
## SN208 type-error
|
|
116
116
|
|
|
@@ -128,9 +128,9 @@ Error, for example:
|
|
|
128
128
|
|
|
129
129
|
> `s` is already defined here by `data-sn-let`; use another name
|
|
130
130
|
|
|
131
|
-
A `data-sn-let` name is inlined wherever it is read, so it cannot share a name with a state variable or loop variable in its scope, or be redeclared inside it.
|
|
131
|
+
A `data-sn-let` name is inlined wherever it is read, so it cannot share a name with a state variable or loop variable in its scope, or be redeclared inside it. A resource cannot take the name of a store function (`refresh`, `upsert`, `patch`, `remove`, `page`, `showModal`, `close`, `reset`, `emit`).
|
|
132
132
|
|
|
133
|
-
**Fix:** Rename the let,
|
|
133
|
+
**Fix:** Rename the let, the state or loop variable, or the resource.
|
|
134
134
|
|
|
135
135
|
## SN301 unknown-resource
|
|
136
136
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tiaforge-ui/sinter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Bind JSON APIs to plain HTML: keyed resources, SSE sync, template bindings and optimistic mutations, compiled at build time.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Tia Heinrich",
|