@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 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);
@@ -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 */
@@ -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
- if (typeof first !== 'string' || !(/^\/[^/\\]/.test(first) || first === '/' || /^https?:\/\/[^/{}]+(\/|$)/i.test(first))) {
1010
- this.report(el, attrName, 'SN207', 'a URL template must start with a literal same-origin path (`/api/…`) or a literal `https://` origin', 0, 'a leading interpolation or `//` lets a value choose the host');
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, or persisted password fields.',
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, or the state or loop variable.',
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',
@@ -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
- url(r.src, `${at}.src`);
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
- url(a.url, `${at}.url`);
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
- else if (target === 'next')
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
- from = move.to != null ? move.to + 1 : null;
145
- to = from != null ? from + n - 1 : null;
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;
@@ -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>;
@@ -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) { config.locale?.set(v); app.mark('$locale'); },
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
- addCleanup: (fn) => void cleanups.push(fn),
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
- timers.set(rb, env.setTimeout(() => { timers.delete(rb); app.busy--; handle(rb, rb.b.f, e, rb.b.w); }, rb.b.ms, 'debounce'));
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
- body = a.body != null ? fns[a.body](s) : fields ? (a.enc == 'form' ? formFields(rb.el) : serialize(rb.el)) : undefined;
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('#'), [base, query] = path.split('?'), q = new URLSearchParams(query);
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
- segs.forEach((s, i) => (o = o[s] ??= /^\d+$/.test(segs[i + 1] ?? k) ? [] : Object.create(null)));
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
@@ -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) {
@@ -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 KB; larger ones and uploads go without it)
58
- const keepalive = method != 'GET' && method != 'HEAD' && (body == null || (typeof body == 'string' && body.length < 60_000));
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();
@@ -160,7 +160,8 @@ export function createResource(name, o) {
160
160
  }
161
161
  };
162
162
  function visible() {
163
- if (view)
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
- for (const def of app.ir.syncs)
205
- new Connection(app, def).start();
206
+ if (alive)
207
+ for (const def of app.ir.syncs)
208
+ new Connection(app, def).start();
206
209
  });
207
210
  },
208
211
  };
@@ -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, or persisted password fields.
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, or the state or loop variable.
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.0",
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",