@jigs.design/client 0.3.0 → 0.3.2

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/client.d.ts CHANGED
@@ -71,6 +71,13 @@ export interface PageResult {
71
71
  export interface JigsClient {
72
72
  /** URL of the sandbox frame document. */
73
73
  readonly frame: string;
74
+ /**
75
+ * Keeps the push channel from closing until the returned function is
76
+ * called; it does not open one. A view holds it from `start` to `stop`,
77
+ * so a program that reads no query for a while, as one still streaming
78
+ * may, does not close and reopen the stream.
79
+ */
80
+ hold: () => () => void;
74
81
  /** Refetches every query with this name, for every input. */
75
82
  invalidate: (name: string) => Promise<void>;
76
83
  /**
package/dist/client.js CHANGED
@@ -6,9 +6,9 @@ const DEFAULT_STALE_TIME = 6e4;
6
6
  const RETRYABLE = /* @__PURE__ */ new Set(["NETWORK", "UNAVAILABLE"]);
7
7
  const MAX_RETRIES = 2;
8
8
  /**
9
- * How long the push channel stays open after the last query unsubscribes:
10
- * a program swap re-creates its observers after a round trip to the
11
- * sandbox, not within a tick.
9
+ * How long the push channel stays open once nothing observes or holds it:
10
+ * a view that remounts, or a block that becomes another, subscribes again
11
+ * well within it.
12
12
  */
13
13
  const CHANNEL_LINGER_MS = 1e3;
14
14
  /** When a channel the server refused or ended is tried again, with queries still observed. */
@@ -16,13 +16,16 @@ const CHANNEL_REOPEN_MS = 3e5;
16
16
  /** The refetch after a gap in the channel waits up to this long, so tabs do not all hit the server at once. */
17
17
  const GAP_SPREAD_MS = 2e3;
18
18
  /**
19
- * The push channel: open while any query or page is observed, and its events
20
- * applied to the cache. It refetches everything on screen only after a gap
19
+ * The push channel: opened by the first query or page observed, kept open
20
+ * while any is observed or a view holds it, and its events applied to the
21
+ * cache. It refetches everything on screen only after a gap
21
22
  * it cannot account for: a connection lost without a `close` event, to a
22
23
  * server that did not replay what it missed.
23
24
  */
24
25
  function createChannel(transport, handlers) {
25
26
  let observers = 0;
27
+ /** Views mounted: they keep an open channel open, but do not open one. */
28
+ let holds = 0;
26
29
  let running = null;
27
30
  let linger;
28
31
  let reopen;
@@ -53,16 +56,32 @@ function createChannel(transport, handlers) {
53
56
  running = controller;
54
57
  consume(transport.events({ signal: controller.signal }), controller.signal);
55
58
  };
59
+ /** Closes the channel after the linger, once nothing observes or holds it. */
60
+ const closeWhenIdle = () => {
61
+ if (observers > 0 || holds > 0) return;
62
+ clearTimeout(linger);
63
+ linger = setTimeout(() => {
64
+ clearTimeout(reopen);
65
+ running?.abort();
66
+ running = null;
67
+ }, CHANNEL_LINGER_MS);
68
+ };
56
69
  return {
70
+ hold: () => {
71
+ holds += 1;
72
+ clearTimeout(linger);
73
+ let held = true;
74
+ return () => {
75
+ if (held) {
76
+ held = false;
77
+ holds -= 1;
78
+ closeWhenIdle();
79
+ }
80
+ };
81
+ },
57
82
  release: () => {
58
83
  observers -= 1;
59
- if (observers > 0) return;
60
- clearTimeout(linger);
61
- linger = setTimeout(() => {
62
- clearTimeout(reopen);
63
- running?.abort();
64
- running = null;
65
- }, CHANNEL_LINGER_MS);
84
+ closeWhenIdle();
66
85
  },
67
86
  retain: () => {
68
87
  observers += 1;
@@ -169,6 +188,7 @@ function createJigsClient(options) {
169
188
  });
170
189
  return {
171
190
  frame: transport.frame,
191
+ hold: channel.hold,
172
192
  invalidate: (name) => queries.invalidateQueries({ queryKey: [name] }),
173
193
  invalidatePage,
174
194
  mutate: async (name, input, procedure) => {
@@ -1,5 +1,5 @@
1
1
  import { ConfirmFunction, JigsClient } from "../client.js";
2
- import { Program, PropSchema, RuntimeError, WireArg } from "@jigs.design/protocol";
2
+ import { JsonValue, Program, PropSchema, RuntimeError, WireArg } from "@jigs.design/protocol";
3
3
  import { TreeNode, TreeStore } from "@jigs.design/runtime";
4
4
  //#region src/view/controller.d.ts
5
5
  /** What the view shows beside the tree: a snapshot, replaced on each change. */
@@ -91,6 +91,14 @@ export interface ViewController {
91
91
  * `signal` aborted.
92
92
  */
93
93
  setConfirmHandler: (handler: ConfirmFunction | undefined) => void;
94
+ /**
95
+ * Replaces the provided data, by slot name. Every read of a slot whose
96
+ * value changed, compared as JSON, gets the new value, as a refetched
97
+ * query's does; a slot left out reads as `UNAVAILABLE`. A value that is
98
+ * the same object as before is not compared again, so a page that keeps
99
+ * its values' identity pays nothing for an unchanged slot.
100
+ */
101
+ setData: (values: Readonly<Record<string, JsonValue>> | undefined) => void;
94
102
  /** Replaces what each new issue is given to; `undefined` stops reporting. */
95
103
  setIssueHandler: (handler: ((issue: ViewIssue) => void) | undefined) => void;
96
104
  /**
@@ -35,6 +35,15 @@ const NO_ACTIONS = {
35
35
  },
36
36
  status: "error"
37
37
  };
38
+ /** What a read of slot `name` gets when the page provides no value for it. */
39
+ const notProvided = (name) => ({
40
+ data: null,
41
+ error: {
42
+ code: "UNAVAILABLE",
43
+ message: `The page provided no "${name}"`
44
+ },
45
+ status: "error"
46
+ });
38
47
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
39
48
  /** The runtime errors of a result, as text React can draw. The frame is not trusted. */
40
49
  function parseErrors(list) {
@@ -73,6 +82,10 @@ function createViewController(client) {
73
82
  const store = createTreeStore();
74
83
  const listeners = /* @__PURE__ */ new Set();
75
84
  const observers = /* @__PURE__ */ new Map();
85
+ /** Reads of provided slots, answered here, never fetched: key to slot name. */
86
+ const slotReads = /* @__PURE__ */ new Map();
87
+ /** The page's provided data, each value with its JSON to compare by. */
88
+ let provided = /* @__PURE__ */ new Map();
76
89
  const sent = /* @__PURE__ */ new Map();
77
90
  let iframe = null;
78
91
  let ready = false;
@@ -107,6 +120,8 @@ function createViewController(client) {
107
120
  * still showing cannot run a mutation for a program that is gone.
108
121
  */
109
122
  let confirmationScope = new AbortController();
123
+ /** Lets the push channel close; set while the view is started. */
124
+ let unhold = null;
110
125
  const withdrawConfirmations = () => {
111
126
  confirmationScope.abort();
112
127
  confirmationScope = new AbortController();
@@ -241,8 +256,18 @@ function createViewController(client) {
241
256
  const dropObservers = () => {
242
257
  for (const unsubscribe of observers.values()) unsubscribe();
243
258
  observers.clear();
259
+ slotReads.clear();
244
260
  sent.clear();
245
261
  };
262
+ /** What a read of slot `name` gets now. */
263
+ const slotResult = (name) => {
264
+ const entry = provided.get(name);
265
+ return entry === void 0 ? notProvided(name) : {
266
+ data: entry.value,
267
+ error: null,
268
+ status: "success"
269
+ };
270
+ };
246
271
  /** The running program's entry for `name`, if it is one of `kinds`. */
247
272
  const procedure = (name, kinds) => {
248
273
  if (running === null || !Object.hasOwn(running.procedures, name)) return;
@@ -253,7 +278,7 @@ function createViewController(client) {
253
278
  const checkedReads = (reads) => {
254
279
  const wanted = /* @__PURE__ */ new Map();
255
280
  for (const read of reads) {
256
- if (!isRecord(read) || typeof read.key !== "string" || typeof read.name !== "string" || procedure(read.name, ["query"]) === void 0) {
281
+ if (!isRecord(read) || typeof read.key !== "string" || typeof read.name !== "string" || procedure(read.name, ["data", "query"]) === void 0) {
257
282
  drop("The sandbox read a query this program does not declare");
258
283
  continue;
259
284
  }
@@ -275,9 +300,20 @@ function createViewController(client) {
275
300
  observers.delete(key);
276
301
  sent.delete(key);
277
302
  }
278
- for (const [key, read] of wanted) if (!observers.has(key)) observers.set(key, client.observe(read.name, read.input, (result) => {
279
- sendQuery(key, result);
280
- }));
303
+ for (const key of slotReads.keys()) if (!wanted.has(key)) {
304
+ slotReads.delete(key);
305
+ sent.delete(key);
306
+ }
307
+ for (const [key, read] of wanted) {
308
+ if (procedure(read.name, ["data"]) !== void 0) {
309
+ slotReads.set(key, read.name);
310
+ sendQuery(key, slotResult(read.name));
311
+ continue;
312
+ }
313
+ if (!observers.has(key)) observers.set(key, client.observe(read.name, read.input, (result) => {
314
+ sendQuery(key, result);
315
+ }));
316
+ }
281
317
  };
282
318
  /** Marks the call `answered` done; returns whether it was a handler call. */
283
319
  const answer = (answered) => {
@@ -478,6 +514,30 @@ function createViewController(client) {
478
514
  setConfirmHandler: (handler) => {
479
515
  confirmHandler = handler;
480
516
  },
517
+ setData: (values) => {
518
+ const next = /* @__PURE__ */ new Map();
519
+ const changed = /* @__PURE__ */ new Set();
520
+ for (const [name, value] of Object.entries(values ?? {})) {
521
+ const before = provided.get(name);
522
+ if (before !== void 0 && Object.is(before.value, value)) {
523
+ next.set(name, before);
524
+ continue;
525
+ }
526
+ const text = JSON.stringify(value);
527
+ if (before?.text === text) {
528
+ next.set(name, before);
529
+ continue;
530
+ }
531
+ next.set(name, {
532
+ text,
533
+ value
534
+ });
535
+ changed.add(name);
536
+ }
537
+ for (const name of provided.keys()) if (!next.has(name)) changed.add(name);
538
+ provided = next;
539
+ for (const [key, name] of slotReads) if (changed.has(name)) sendQuery(key, slotResult(name));
540
+ },
481
541
  setIssueHandler: (handler) => {
482
542
  issueHandler = handler;
483
543
  },
@@ -497,6 +557,7 @@ function createViewController(client) {
497
557
  if (iframe === frame && !ready && startup === null) startup = setTimeout(diagnoseFrame, STARTUP_MS);
498
558
  });
499
559
  iframe = frame;
560
+ unhold = client.hold();
500
561
  window.addEventListener("message", onMessage);
501
562
  document.body.append(frame);
502
563
  },
@@ -520,8 +581,11 @@ function createViewController(client) {
520
581
  latestDocument = void 0;
521
582
  actionHandler = void 0;
522
583
  confirmHandler = void 0;
584
+ provided = /* @__PURE__ */ new Map();
523
585
  withdrawConfirmations();
524
586
  dropObservers();
587
+ unhold?.();
588
+ unhold = null;
525
589
  store.reset();
526
590
  status = idle;
527
591
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jigs.design/client",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "createJigsClient: the browser's transport and query cache. No React.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@tanstack/query-core": "5.104.4",
29
- "@jigs.design/protocol": "0.3.0",
30
- "@jigs.design/runtime": "0.3.0"
29
+ "@jigs.design/runtime": "0.3.2",
30
+ "@jigs.design/protocol": "0.3.2"
31
31
  }
32
32
  }