@tiaforge-ui/sinter 0.1.0 → 0.2.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.
@@ -30,11 +30,10 @@ const after = (el) => {
30
30
  const ET = EventTarget.prototype;
31
31
  /** Per-element registries, kept on the element (cheaper than WeakMaps on the row creation path). */
32
32
  const HANDLERS = Symbol('sinter.on'), MODEL = Symbol('sinter.model'), STATE = Symbol('sinter.state');
33
- const dialogsIn = new WeakMap(), indexReaders = new WeakMap();
33
+ const indexReaders = new WeakMap();
34
34
  /** row templates: whether each holds one element (see oneElement()) */
35
35
  const prepared = new WeakMap();
36
36
  const simple = new WeakMap(), SIMPLE = new Set(['text', 'attr', 'class', 'style', 'show', 'prop', 'on']);
37
- const hasDialog = (tpl) => dialogsIn.get(tpl) ?? (dialogsIn.set(tpl, !!tpl.content.querySelector('dialog')), dialogsIn.get(tpl));
38
37
  /** The instance mount() created on `root`; throws if none. */
39
38
  export function instance(root) {
40
39
  const app = instances.get(root);
@@ -76,6 +75,22 @@ export function mount(root, o) {
76
75
  };
77
76
  /** Elements written through callbacks (`$.$m(row, …)`) since the last flush. */
78
77
  const marked = new Set();
78
+ /** debounced handlers' pending timers */
79
+ const timers = new WeakMap();
80
+ /** action calls' signal: aborts when the page is hidden */
81
+ let hide = new AbortController();
82
+ /** runs an action function; it counts as busy (for the testing module's settle) while it runs */
83
+ async function invoke(run, args, ctx) {
84
+ app.busy++;
85
+ try {
86
+ return await fns[run](args, ctx);
87
+ }
88
+ finally {
89
+ app.busy--;
90
+ }
91
+ }
92
+ /** a failed call: SinterErrors as they are, anything else with status null */
93
+ const callError = (e) => (e instanceof SinterError ? e : new SinterError(null, e, e?.message || String(e)));
79
94
  /** Per flush round: how dirty paths reach a template's rows (0 not, 1 only marked elements, 2 any row). */
80
95
  let rowHits = new Map();
81
96
  /**
@@ -170,6 +185,7 @@ export function mount(root, o) {
170
185
  c();
171
186
  instances.delete(root);
172
187
  },
188
+ scopeOf: (i) => (i < 0 ? app.page : rootScopes[i] ?? app.page),
173
189
  url(tpl, s) {
174
190
  let out = '', query = false, dropped = false;
175
191
  tpl.forEach((p, i) => {
@@ -298,6 +314,10 @@ export function mount(root, o) {
298
314
  }
299
315
  for (const def of ir.resources)
300
316
  createLoader(def);
317
+ // action calls see the page leave through ctx.signal
318
+ if (ir.actions.some((x) => x.call) || ir.resources.some((x) => x.call)) {
319
+ app.addCleanup(env.listen('pagehide', () => { hide.abort(); hide = new AbortController(); }));
320
+ }
301
321
  // listeners for every event type the page binds, rows included
302
322
  listen('toggle');
303
323
  for (const nd of [...ir.roots, ...ir.templates.flatMap((t) => t.nodes)])
@@ -390,10 +410,10 @@ export function mount(root, o) {
390
410
  cleanups.push(() => ET.removeEventListener.call(root, type, dispatch, true));
391
411
  }
392
412
  /**
393
- * `dialogs`: whether these elements can sit in a closed dialog of their own block. Rows only
394
- * need the check when their template contains a dialog; outer dialogs defer the whole list.
413
+ * Only elements the compiler marked as in a dialog of their own block (`dl`) are checked for a
414
+ * closed dialog; dialogs outside a template defer the whole list.
395
415
  */
396
- function bindNodes(defs, els, base, rbs, dialogs = true) {
416
+ function bindNodes(defs, els, base, rbs) {
397
417
  // scopes of elements that declare state (and their descendants); most rows have none
398
418
  let scopes = null;
399
419
  for (let i = 0; i < defs.length; i++) {
@@ -416,7 +436,7 @@ export function mount(root, o) {
416
436
  rootScopes[i] = s;
417
437
  if (el)
418
438
  for (const b of nd.b) {
419
- const rb = { b, el, s, v: UNSET, c: dialogs };
439
+ const rb = { b, el, s, v: UNSET, c: !!nd.dl };
420
440
  if (b.k == 'on') {
421
441
  const h = el[HANDLERS];
422
442
  el[HANDLERS] = h == null ? rb : Array.isArray(h) ? (h.push(rb), h) : [h, rb];
@@ -484,7 +504,7 @@ export function mount(root, o) {
484
504
  /** Evaluates and writes one binding if needed; false while it waits in a closed dialog. */
485
505
  function process(rb, level) {
486
506
  const force = level == 2 || (level == 1 && !!rb.b.i);
487
- if (!force && !deferred.has(rb) && !(rb.b.d && hit(depsOf(rb.b, rb.b.d), rb.s)))
507
+ if (!force && !(deferred.size && deferred.has(rb)) && !(rb.b.d && hit(rb.d ??= depsOf(rb.b, rb.b.d), rb.s)))
488
508
  return true;
489
509
  if (rb.c && rb.el.closest('dialog:not([open])')) {
490
510
  deferred.add(rb);
@@ -762,7 +782,7 @@ export function mount(root, o) {
762
782
  els[k] = node && node.localName == defs[k].tag ? node : null;
763
783
  }
764
784
  }
765
- if (isSimple(tpl, t)) {
785
+ if (isSimple(t)) {
766
786
  // only value bindings and handlers: bind and write each once, as walk(rbs, 2) would
767
787
  for (let k = 0; k < defs.length; k++) {
768
788
  const el = els[k];
@@ -782,7 +802,7 @@ export function mount(root, o) {
782
802
  }
783
803
  }
784
804
  else {
785
- bindNodes(defs, els, s, rbs, hasDialog(tpl));
805
+ bindNodes(defs, els, s, rbs);
786
806
  walk(rbs, 2);
787
807
  }
788
808
  const nodes = [];
@@ -804,10 +824,10 @@ export function mount(root, o) {
804
824
  return one;
805
825
  }
806
826
  /** A row template whose elements only have value bindings and handlers (no state, model, nested template or dialog). */
807
- function isSimple(tpl, t) {
827
+ function isSimple(t) {
808
828
  let v = simple.get(t);
809
829
  if (v == null) {
810
- v = !hasDialog(tpl) && t.nodes.every((nd) => nd.state == null && nd.b.every((b) => SIMPLE.has(b.k)));
830
+ v = t.nodes.every((nd) => !nd.dl && nd.state == null && nd.b.every((b) => SIMPLE.has(b.k)));
811
831
  simple.set(t, v);
812
832
  }
813
833
  return v;
@@ -843,7 +863,7 @@ export function mount(root, o) {
843
863
  const h = el[HANDLERS], hs = h && (Array.isArray(h) ? h : [h]).filter((x) => x.b.n == e.type);
844
864
  if (hs?.length) {
845
865
  for (const h of hs)
846
- h.b.x != null ? trigger(h, e) : handle(h, h.b.f, e, h.b.w);
866
+ h.b.x != null ? trigger(h, e) : h.b.ms ? later(h, e) : handle(h, h.b.f, e, h.b.w);
847
867
  return;
848
868
  }
849
869
  if (el == root)
@@ -864,6 +884,16 @@ export function mount(root, o) {
864
884
  if (writes?.some((w) => w.startsWith('$url')))
865
885
  writeUrl();
866
886
  }
887
+ /** A debounced handler: runs once `ms` after the last event, with that event. */
888
+ function later(rb, e) {
889
+ const t = timers.get(rb);
890
+ if (t) {
891
+ env.clearTimeout(t);
892
+ app.busy--;
893
+ }
894
+ app.busy++;
895
+ timers.set(rb, env.setTimeout(() => { timers.delete(rb); app.busy--; handle(rb, rb.b.f, e, rb.b.w); }, rb.b.ms, 'debounce'));
896
+ }
867
897
  // ---- actions
868
898
  function trigger(rb, e) {
869
899
  const a = ir.actions[rb.b.x], el = rb.el;
@@ -889,10 +919,15 @@ export function mount(root, o) {
889
919
  if (!get && a.concurrency == 'block' && st.n)
890
920
  return;
891
921
  const s = child(rb.s, { $el: rb.el, $event: e });
892
- let url, body;
922
+ let url = '', body;
893
923
  try {
894
- url = app.url(a.url, s);
895
- body = a.body != null ? fns[a.body](s) : a.form && !get && a.method != 'DELETE' ? serialize(rb.el) : undefined;
924
+ // a call's arguments are taken when it is triggered, like a request's URL and body
925
+ if (a.call)
926
+ body = fns[a.call.args](s);
927
+ else {
928
+ url = app.url(a.url, s);
929
+ body = a.body != null ? fns[a.body](s) : a.form && !get && a.method != 'DELETE' ? serialize(rb.el) : undefined;
930
+ }
896
931
  }
897
932
  catch (err) {
898
933
  return unhandled(toError(err), rb.el);
@@ -924,7 +959,9 @@ export function mount(root, o) {
924
959
  x.disabled = true;
925
960
  }
926
961
  try {
927
- const result = await req.send(url, { method: a.method, body, signal: c?.signal });
962
+ const result = a.call
963
+ ? await invoke(a.call.run, body, { signal: hide.signal, request: (u, i) => req.send(u, i), el })
964
+ : await req.send(url, { method: a.method, body, signal: c?.signal });
928
965
  if (c && st.c != c)
929
966
  return;
930
967
  drop();
@@ -935,7 +972,7 @@ export function mount(root, o) {
935
972
  drop();
936
973
  if (isAbort(err) || (c && st.c != c))
937
974
  return;
938
- const error = toError(err);
975
+ const error = a.call ? callError(err) : toError(err);
939
976
  a.catch != null ? handle(rb, a.catch, null, a.w, { $error: error }) : unhandled(error, el);
940
977
  }
941
978
  finally {
@@ -968,14 +1005,34 @@ export function mount(root, o) {
968
1005
  }
969
1006
  // ---- resources
970
1007
  function createLoader(def) {
971
- const r = createResource(def.name, { key: def.key, order: def.order, locale: () => config.locale?.get(), warn, prep: (x) => (app.prep ? app.prep(x) : x) });
1008
+ const r = createResource(def.name, { key: def.key, order: def.order, locale: () => config.locale?.get(), warn, prep: (x) => (app.prep ? app.prep(x) : x), freeze: app.dev });
972
1009
  r.onDirty = (what) => app.mark((what == 'data' ? '$r.' : '$meta.') + def.name);
973
1010
  resources.set(def.name, r);
974
1011
  Object.defineProperty(app.page.$r, def.name, { get: r.value, enumerable: true });
975
1012
  app.page.$meta[def.name] = r.meta;
976
1013
  let url = null, c = null, seq = 0, again = null;
1014
+ const scope = () => app.scopeOf(def.scope);
1015
+ /** `call`: the arguments of the current load; the identity of a load is its arguments' JSON */
1016
+ let args = [], calls = 0;
977
1017
  const resolve = () => {
978
- const s = def.scope < 0 ? app.page : rootScopes[def.scope] ?? app.page;
1018
+ const s = scope();
1019
+ if (def.call) {
1020
+ try {
1021
+ if (def.when != null && !fns[def.when](s))
1022
+ return null;
1023
+ args = fns[def.call.args](s);
1024
+ try {
1025
+ return 'call:' + JSON.stringify(args);
1026
+ }
1027
+ catch {
1028
+ return `call:#${++calls}`;
1029
+ }
1030
+ }
1031
+ catch (e) {
1032
+ warn(17, def.name, e.message);
1033
+ return null;
1034
+ }
1035
+ }
979
1036
  try {
980
1037
  return def.when != null && !fns[def.when](s) ? null : app.url(def.src, s);
981
1038
  }
@@ -1010,8 +1067,23 @@ export function mount(root, o) {
1010
1067
  };
1011
1068
  /** the request URL for the current page of `u` */
1012
1069
  const request = (u, m = l.pager?.current()) => withParams(u, m ? l.pager.params(m) : null);
1070
+ const fail = (e) => {
1071
+ const error = toError(e);
1072
+ r.meta.error = error;
1073
+ r.onDirty('meta');
1074
+ unhandled(error, null, def.name);
1075
+ };
1013
1076
  const l = {
1014
1077
  def, res: r, app, pager: null,
1078
+ scope, get: loadShared, failed: fail, loading,
1079
+ items(body) {
1080
+ let items = pick(def.items, body);
1081
+ if (!Array.isArray(items)) {
1082
+ warn(12, def.name, def.items ?? '(response)');
1083
+ items = [];
1084
+ }
1085
+ return items.map(clean);
1086
+ },
1015
1087
  start() {
1016
1088
  const el = def.data != null ? found.get(def.data) : null;
1017
1089
  let data;
@@ -1069,7 +1141,9 @@ export function mount(root, o) {
1069
1141
  loading(true);
1070
1142
  try {
1071
1143
  const req = withParams(url, m ? l.pager.params(m) : null);
1072
- const body = await loadShared(req, ctl.signal);
1144
+ const body = def.call
1145
+ ? await invoke(def.call.run, args, { signal: ctl.signal, request: (u, i) => app.req.send(u, i), el: null })
1146
+ : await loadShared(req, ctl.signal);
1073
1147
  if (n != seq)
1074
1148
  return;
1075
1149
  store(body, req, m);
@@ -1080,10 +1154,7 @@ export function mount(root, o) {
1080
1154
  catch (e) {
1081
1155
  if (n != seq || isAbort(e))
1082
1156
  return;
1083
- const error = toError(e);
1084
- r.meta.error = error;
1085
- r.onDirty('meta');
1086
- unhandled(error, null, def.name);
1157
+ fail(def.call ? callError(e) : e);
1087
1158
  }
1088
1159
  finally {
1089
1160
  // writes made while this load ran win over its (older) response
@@ -1095,6 +1166,8 @@ export function mount(root, o) {
1095
1166
  }
1096
1167
  },
1097
1168
  };
1169
+ if (def.each)
1170
+ app.ext.each.fanOut(l);
1098
1171
  if (def.paginate)
1099
1172
  l.pager = app.ext.paginate.pager(l);
1100
1173
  r.loader = l;
@@ -19,11 +19,11 @@ export declare const config: Config;
19
19
  export declare function configure(options: Partial<Omit<Config, 'env'>> & {
20
20
  env?: Partial<Env>;
21
21
  }): void;
22
- /** `$error`: a non-2xx response, a network error (status 0) or a refused request. */
22
+ /** `$error`: a non-2xx response, a network error (status 0), a refused request, or a failed action call (status null). */
23
23
  export declare class SinterError extends Error {
24
- readonly status: number;
24
+ readonly status: number | null;
25
25
  readonly body: unknown;
26
- constructor(status: number, body: unknown, message: string);
26
+ constructor(status: number | null, body: unknown, message: string);
27
27
  }
28
28
  /** page-load cache entries (`cache` on `<sn-resource>`), in sessionStorage */
29
29
  export declare const CACHE_PREFIX = "sn:c:";
@@ -10,7 +10,7 @@ export function configure(options) {
10
10
  if (env)
11
11
  config.env = { ...config.env, ...env };
12
12
  }
13
- /** `$error`: a non-2xx response, a network error (status 0) or a refused request. */
13
+ /** `$error`: a non-2xx response, a network error (status 0), a refused request, or a failed action call (status null). */
14
14
  export class SinterError extends Error {
15
15
  status;
16
16
  body;
@@ -29,14 +29,14 @@ export type Op = {
29
29
  export interface Meta {
30
30
  loading: boolean;
31
31
  error: {
32
- status: number;
32
+ status: number | null;
33
33
  body: unknown;
34
34
  message: string;
35
35
  } | null;
36
36
  loadedAt: number | null;
37
37
  pending: number;
38
38
  total: number | null;
39
- page?: number;
39
+ page?: number | null;
40
40
  pages?: number | null;
41
41
  from?: number | null;
42
42
  to?: number | null;
@@ -50,6 +50,8 @@ export interface ResourceOptions {
50
50
  warn?: (code: number, ...args: unknown[]) => void;
51
51
  /** applied to every stored item (dev builds freeze them) */
52
52
  prep?: <T>(item: T) => T;
53
+ /** hand out frozen lists (dev builds) */
54
+ freeze?: boolean;
53
55
  }
54
56
  /** The public store handle (`app.resource(name)`), plus the hooks loaders and features use. */
55
57
  export interface Resource<T extends object = any> {
@@ -66,7 +68,8 @@ export interface Resource<T extends object = any> {
66
68
  upsert(item: T): void;
67
69
  patch(key: string | T, fields: Partial<T>): void;
68
70
  remove(key: string | T): void;
69
- refetch(): Promise<void>;
71
+ /** `hint`: a server event's payload; an `each` resource reloads only the source it names */
72
+ refetch(hint?: object): Promise<void>;
70
73
  subscribe(fn: (change: Change<T>) => void): () => void;
71
74
  keyOf(item: string | T): string;
72
75
  /**
@@ -86,7 +89,7 @@ export interface Resource<T extends object = any> {
86
89
  /** while set, upsert/patch/remove are recorded instead of applied */
87
90
  recording: Op[] | null;
88
91
  loader: {
89
- refetch(): Promise<void>;
92
+ refetch(hint?: object): Promise<void>;
90
93
  } | null;
91
94
  onDirty(what: 'data' | 'meta'): void;
92
95
  }
@@ -12,13 +12,27 @@ export function clean(obj) {
12
12
  out[k] = obj[k];
13
13
  return out;
14
14
  }
15
+ /** One collator per locale: constructing one is costly in some engines. */
16
+ const collators = new Map();
17
+ const collator = (l = '') => collators.get(l) ?? (collators.set(l, new Intl.Collator(l || undefined, { numeric: true })), collators.get(l));
15
18
  export function createResource(name, o) {
16
19
  const meta = { loading: false, error: null, loadedAt: null, pending: 0, total: null };
17
20
  const coll = !!o.key, field = o.order?.replace(/^-/, ''), dir = o.order?.[0] == '-' ? -1 : 1;
18
21
  const subs = new Set();
19
22
  const prep = (x) => (o.prep ? o.prep(x) : x);
20
- let base = new Map(), single = null, total = null;
23
+ /** base items in arrival order, and each key's arrival number (ties in `order` keep arrival order) */
24
+ let base = new Map(), seq = new Map(), next = 0;
25
+ let single = null, total = null;
21
26
  let layers = [], lastLayer = 0;
27
+ /**
28
+ * The base in display order, the locale it was sorted for, and the keys written since, with
29
+ * their value before the first write. Reads patch it instead of sorting
30
+ * again. Arrays handed out are never changed afterwards.
31
+ */
32
+ let sorted = null, sortedFor, touched = new Map();
33
+ /** Optimistic layers applied over the base: changed keys (null: removed), keys they add (arrival numbers), removals counted for `total`. */
34
+ let over = null;
35
+ /** what readers see; rebuilt on the first read after a change */
22
36
  let view = null;
23
37
  let journal = null, loads = 0;
24
38
  const keyOf = (x) => typeof x == 'string' ? x : (o.key ?? []).map((f) => {
@@ -29,71 +43,171 @@ export function createResource(name, o) {
29
43
  const merge = (cur, op) => op.op == 'upsert' ? prep(op.item)
30
44
  : op.op == 'remove' ? null
31
45
  : cur && prep({ ...cur, ...clean(op.fields) });
32
- /** Applies one op to a keyed map; counts removals of present items. */
33
- const applyTo = (m, op, removed) => {
34
- const k = op.op == 'upsert' ? keyOf(op.item) : op.key, v = merge(m.get(k), op);
35
- if (v)
36
- m.set(k, v);
37
- else if (m.delete(k) && removed)
38
- removed.n++;
46
+ /** Applies one op to the base. */
47
+ const apply = (op) => {
48
+ if (!coll)
49
+ return void (single = merge(single, op));
50
+ const k = op.op == 'upsert' ? keyOf(op.item) : op.key, cur = base.get(k), v = merge(cur, op);
51
+ if (sorted && !touched.has(k))
52
+ touched.set(k, cur);
53
+ if (v) {
54
+ if (!cur)
55
+ seq.set(k, next++);
56
+ base.set(k, v);
57
+ }
58
+ else if (cur) {
59
+ base.delete(k);
60
+ seq.delete(k);
61
+ }
39
62
  };
40
- const changed = (what = 'data') => {
63
+ /** By `order` (none: all equal, so arrival order decides). */
64
+ const compare = (a, b) => {
65
+ if (!field)
66
+ return 0;
67
+ const x = a[field], y = b[field];
68
+ if (x == null || y == null)
69
+ return +(x == null) - +(y == null);
70
+ return dir * (typeof x == 'string' && typeof y == 'string' ? collator(sortedFor).compare(x, y) : +(x > y) - +(x < y));
71
+ };
72
+ /** A sorted copy of `list` without the `drop` items and with the `add` items (each with its arrival number). */
73
+ function patch(list, drop, add, arrival) {
74
+ const out = list.slice();
75
+ /** the first index whose item does not sort before `v` (with arrival number `n`, after its ties) */
76
+ const find = (v, n) => {
77
+ let lo = 0, hi = out.length;
78
+ while (lo < hi) {
79
+ const mid = (lo + hi) >> 1, c = compare(out[mid], v) || (n == null ? 0 : arrival(keyOf(out[mid])) - n);
80
+ c < 0 ? (lo = mid + 1) : (hi = mid);
81
+ }
82
+ return lo;
83
+ };
84
+ for (const v of drop) {
85
+ // among its ties, by identity; an item whose order field was changed in place is found by a scan
86
+ let i = find(v);
87
+ while (out[i] !== v && i < out.length && !compare(out[i], v))
88
+ i++;
89
+ out.splice(out[i] === v ? i : out.indexOf(v), 1);
90
+ }
91
+ for (const [v, n] of add)
92
+ out.splice(find(v, n), 0, v);
93
+ return out;
94
+ }
95
+ /** The base in display order. */
96
+ function baseList() {
97
+ const locale = o.locale?.();
98
+ if (sorted && locale === sortedFor && touched.size <= 64) {
99
+ if (touched.size) {
100
+ const drop = [], add = [];
101
+ for (const [k, old] of touched) {
102
+ if (old)
103
+ drop.push(old);
104
+ const v = base.get(k);
105
+ if (v)
106
+ add.push([v, seq.get(k)]);
107
+ }
108
+ sorted = patch(sorted, drop, add, (k) => seq.get(k));
109
+ }
110
+ }
111
+ else {
112
+ sortedFor = locale;
113
+ // a stable sort of the Map's arrival order
114
+ sorted = [...base.values()].sort(compare);
115
+ }
116
+ touched = new Map();
117
+ return sorted;
118
+ }
119
+ /** Recomputes the layers' effect and `meta.total` after any change; marks `meta` if the total changed. */
120
+ function sync() {
121
+ over = null;
122
+ if (layers.length) {
123
+ const m = new Map(), add = new Map();
124
+ let removed = 0, n = next, s = single;
125
+ for (const l of layers)
126
+ for (const op of l.ops) {
127
+ if (!coll) {
128
+ s = merge(s, op);
129
+ continue;
130
+ }
131
+ const k = op.op == 'upsert' ? keyOf(op.item) : op.key, cur = m.has(k) ? m.get(k) : base.get(k), v = merge(cur, op);
132
+ if (v) {
133
+ if (!cur)
134
+ add.set(k, n++);
135
+ m.set(k, v);
136
+ }
137
+ else {
138
+ if (cur)
139
+ removed++;
140
+ m.set(k, null);
141
+ add.delete(k);
142
+ }
143
+ }
144
+ over = { m, add, removed, s };
145
+ }
146
+ const t = total == null ? null : Math.max(0, total - (over?.removed ?? 0));
147
+ if (meta.total !== t) {
148
+ meta.total = t;
149
+ r.onDirty('meta');
150
+ }
151
+ }
152
+ /** A data change: readers rebuild their view; `meta` too when the pending count changed. */
153
+ const changed = (pending) => {
41
154
  view = null;
155
+ sync();
42
156
  r.onDirty('data');
43
- if (what == 'meta')
157
+ if (pending) {
158
+ meta.pending = layers.length;
44
159
  r.onDirty('meta');
160
+ }
45
161
  };
46
162
  function visible() {
47
163
  if (view)
48
164
  return view;
49
- let t = total, m = base, s = single;
50
- const removed = { n: 0 };
51
- for (const l of layers)
52
- for (const op of l.ops)
53
- coll ? (m == base && (m = new Map(base)), applyTo(m, op, removed)) : (s = merge(s, op));
54
- if (t != null)
55
- t = Math.max(0, t - removed.n);
56
- const l = [...m.values()];
57
- if (field) {
58
- const c = new Intl.Collator(o.locale?.(), { numeric: true });
59
- l.sort((a, b) => {
60
- const x = a[field], y = b[field];
61
- if (x == null || y == null)
62
- return +(x == null) - +(y == null);
63
- return dir * (typeof x == 'string' && typeof y == 'string' ? c.compare(x, y) : +(x > y) - +(x < y));
64
- });
65
- }
66
- view = { l: Object.freeze(l), m, s };
67
- if (meta.total !== t) {
68
- meta.total = t;
69
- r.onDirty('meta');
165
+ if (!coll)
166
+ return (view = { l: [], get: () => undefined, s: over ? over.s : single });
167
+ let l = baseList(), get = (k) => base.get(k);
168
+ if (over) {
169
+ const { m, add } = over, drop = [], put = [], at = (k) => add.get(k) ?? seq.get(k);
170
+ for (const [k, v] of m) {
171
+ const b = base.get(k);
172
+ if (b)
173
+ drop.push(b);
174
+ if (v)
175
+ put.push([v, at(k)]);
176
+ }
177
+ l = patch(l, drop, put, at);
178
+ get = (k) => (m.has(k) ? m.get(k) ?? undefined : base.get(k));
70
179
  }
71
- return view;
180
+ // dev builds freeze what expressions see; the compiler refuses writes to resources (SN205)
181
+ return (view = { l: o.freeze ? Object.freeze(l.slice()) : l, get, s: null });
72
182
  }
73
183
  function write(op) {
74
184
  if (r.recording)
75
185
  return void r.recording.push(op);
76
186
  journal?.push(op);
77
- coll ? applyTo(base, op) : (single = merge(single, op));
187
+ apply(op);
78
188
  changed();
79
189
  for (const f of subs)
80
190
  f({ resource: name, ...op });
81
191
  }
82
192
  const r = {
83
193
  name, meta, key: o.key, isCollection: coll, keyOf, recording: null, loader: null, onDirty() { },
84
- get: (k) => (coll ? visible().m.get(keyOf(k)) : visible().s ?? undefined),
194
+ get: (k) => (coll ? visible().get(keyOf(k)) : visible().s ?? undefined),
85
195
  has: (k) => r.get(k) !== undefined,
86
196
  all: () => visible().l,
87
197
  value: () => (coll ? visible().l : visible().s),
88
198
  replace(items) {
89
199
  if (coll) {
90
200
  base = new Map();
201
+ seq = new Map();
202
+ sorted = null;
203
+ touched = new Map();
91
204
  for (const x of Array.isArray(items) ? items : []) {
92
205
  const k = keyOf(x);
93
206
  if (base.has(k))
94
207
  o.warn?.(13, name, k);
95
208
  base.delete(k);
96
209
  base.set(k, prep(x));
210
+ seq.set(k, next++);
97
211
  }
98
212
  }
99
213
  else
@@ -105,7 +219,7 @@ export function createResource(name, o) {
105
219
  upsert: (item) => write({ op: 'upsert', item }),
106
220
  patch: (k, fields) => write({ op: 'patch', key: keyOf(k), fields }),
107
221
  remove: (k) => write({ op: 'remove', key: keyOf(k) }),
108
- refetch: () => r.loader?.refetch() ?? Promise.resolve(),
222
+ refetch: (hint) => r.loader?.refetch(hint) ?? Promise.resolve(),
109
223
  subscribe: (fn) => (subs.add(fn), () => subs.delete(fn)),
110
224
  startLoad() {
111
225
  loads++;
@@ -119,23 +233,22 @@ export function createResource(name, o) {
119
233
  if (!loaded || !ops.length)
120
234
  return;
121
235
  for (const op of ops)
122
- coll ? applyTo(base, op) : (single = merge(single, op));
236
+ apply(op);
123
237
  changed();
124
238
  },
125
239
  addLayer(ops) {
126
240
  layers.push({ id: ++lastLayer, ops });
127
- meta.pending = layers.length;
128
- changed('meta');
241
+ changed(true);
129
242
  return lastLayer;
130
243
  },
131
244
  dropLayer(id) {
132
245
  const n = layers.length;
133
246
  layers = layers.filter((l) => l.id != id);
134
- meta.pending = layers.length;
135
247
  if (n != layers.length)
136
- changed('meta');
248
+ changed(true);
137
249
  },
138
- setTotal(t) { total = t; changed(); },
250
+ /** the total changes `meta` only: bindings that read the items do not re-run */
251
+ setTotal(t) { total = t; sync(); },
139
252
  base: () => ({ items: coll ? [...base.values()] : single, total }),
140
253
  };
141
254
  return r;
package/dist/sse.js CHANGED
@@ -45,10 +45,13 @@ class Connection {
45
45
  everOpen = false;
46
46
  url;
47
47
  fed;
48
+ /** where `src` and the events' expressions are evaluated: the enclosing state's scope, or the page */
49
+ base;
48
50
  constructor(app, def) {
49
51
  this.app = app;
50
52
  this.def = def;
51
- this.url = app.url(def.src, app.page);
53
+ this.base = app.scopeOf(def.scope ?? -1);
54
+ this.url = app.url(def.src, this.base);
52
55
  this.fed = [...new Set(def.events.map((e) => e.resource))];
53
56
  }
54
57
  start() {
@@ -116,7 +119,7 @@ class Connection {
116
119
  status = (await this.app.req.raw(config.sessionProbe)).status;
117
120
  }
118
121
  catch (e) {
119
- status = e instanceof SinterError ? e.status : 0;
122
+ status = e instanceof SinterError ? e.status ?? 0 : 0;
120
123
  }
121
124
  if (this.closed || this.hidden)
122
125
  return;
@@ -139,7 +142,7 @@ class Connection {
139
142
  }
140
143
  apply(def, payload) {
141
144
  const app = this.app;
142
- const scope = Object.create(app.page);
145
+ const scope = Object.create(this.base);
143
146
  scope.$p = payload && typeof payload === 'object' ? payload : {};
144
147
  const r = app.resource(def.resource);
145
148
  try {
@@ -148,13 +151,13 @@ class Connection {
148
151
  const paginated = !!app.ir.resources.find((x) => x.name === def.resource)?.paginate;
149
152
  switch (def.op) {
150
153
  case 'refetch':
151
- void r.refetch();
154
+ void r.refetch(scope.$p);
152
155
  return;
153
156
  case 'upsert': {
154
157
  const item = def.item != null ? app.fns[def.item](scope) : payload;
155
158
  if (item == null || typeof item !== 'object') {
156
159
  if (def.fallback === 'refetch')
157
- void r.refetch();
160
+ void r.refetch(scope.$p);
158
161
  return;
159
162
  }
160
163
  if (paginated && !r.has(item)) {
@@ -44,6 +44,8 @@ export interface RenderOptions {
44
44
  now?: number | Date;
45
45
  /** helper functions, and namespaces as objects of functions */
46
46
  helpers?: Record<string, unknown>;
47
+ /** action functions for `data-sn-call` and `<sn-resource call>`, and namespaces as objects of functions */
48
+ actions?: Record<string, unknown>;
47
49
  /** partials for `<sn-include>`, by path relative to the page */
48
50
  files?: Record<string, string>;
49
51
  /** localStorage and sessionStorage (default: empty ones); pass the previous page's to test what carries over */