querysub 0.165.0 → 0.167.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.165.0",
3
+ "version": "0.167.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -24,9 +24,9 @@
24
24
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
25
25
  "pako": "^2.1.0",
26
26
  "preact": "^10.11.3",
27
- "socket-function": "^0.90.0",
27
+ "socket-function": "^0.105.0",
28
28
  "terser": "^5.31.0",
29
- "typesafecss": "^0.6.3",
29
+ "typesafecss": "^0.15.0",
30
30
  "yaml": "^2.5.0",
31
31
  "yargs": "^15.3.1"
32
32
  },
@@ -1,3 +1,5 @@
1
+ import { MaybePromise } from "socket-function/src/types";
2
+ import { CallInterceptor } from "../3-path-functions/PathFunctionHelpers";
1
3
  import type { EdgeNodeConfig } from "../4-deploy/edgeNodes";
2
4
  import type { ExtraMetadata } from "../5-diagnostics/nodeMetadata";
3
5
 
@@ -121,13 +123,18 @@ export const isManagementUser = createHookFunctionReturn<
121
123
 
122
124
 
123
125
 
124
- declare global {
125
- var BOOTED_EDGE_NODE: EdgeNodeConfig | undefined;
126
- }
127
-
128
126
 
129
127
  export function getBootedEdgeNode(): EdgeNodeConfig {
130
128
  let edgeNode = globalThis.BOOTED_EDGE_NODE;
131
129
  if (!edgeNode) throw new Error(`No edge node booted? This should be impossible.`);
132
- return edgeNode;
133
- }
130
+ return edgeNode as EdgeNodeConfig;
131
+ }
132
+
133
+
134
+ export const interceptCalls = createHookFunctionReturn<
135
+ <T>(interceptor: CallInterceptor<T>) => T
136
+ >("interceptCalls");
137
+
138
+ export const onAllPredictionsFinished = createHookFunctionReturn<
139
+ () => MaybePromise<void>
140
+ >("onAllPredictionsFinished");
@@ -21,7 +21,7 @@ export async function signWithPEM(config: {
21
21
  let signature = await signED25519(key, inputMessage);
22
22
  return signature;
23
23
  } else {
24
- let key = await globalThis.crypto.subtle.importKey("spki", Buffer.from(pem), { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]);
24
+ let key = await globalThis.crypto.subtle.importKey("spki", Buffer.from(pem as any), { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"]);
25
25
  let signatureBuffer = await globalThis.crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, inputMessage);
26
26
  return Buffer.from(signatureBuffer);
27
27
  }
@@ -523,9 +523,9 @@ const NodeDiscoveryController = SocketFunction.register(
523
523
  getAllNodesHash: { hooks: [requiresNetworkTrustHook] },
524
524
  // Skip client hooks, so we don't block on authentication (IdentityController), as some of these functions
525
525
  // are needed for authentication to finish!
526
- getAllNodeIds: { noClientHooks: true },
527
- getNodeId: { noClientHooks: true },
528
- isNoNetwork: { noClientHooks: true },
526
+ getAllNodeIds: { noClientHooks: true, noDefaultHooks: true },
527
+ getNodeId: { noClientHooks: true, noDefaultHooks: true },
528
+ isNoNetwork: { noClientHooks: true, noDefaultHooks: true },
529
529
  }),
530
530
  () => ({
531
531
 
@@ -371,8 +371,6 @@ class PathValueCommitter {
371
371
  });
372
372
  if (values.length === 0) continue;
373
373
 
374
- debugbreak(2);
375
- debugger;
376
374
  console.log(self.getExistingWatchRemoteNodeId(childPath));
377
375
  for (let value of values) {
378
376
  specialInitialParentCausedRejections.push({
@@ -30,10 +30,10 @@ import type { FunctionMetadata } from "../3-path-functions/syncSchema";
30
30
 
31
31
  import { DEPTH_TO_DATA, MODULE_INDEX, getCurrentCall, getCurrentCallObj } from "../3-path-functions/PathFunctionRunner";
32
32
  import { inlineNestedCalls } from "../3-path-functions/syncSchema";
33
- import { interceptCalls, runCall } from "../3-path-functions/PathFunctionHelpers";
33
+ import { interceptCallsBase, runCall } from "../3-path-functions/PathFunctionHelpers";
34
34
  import { deepCloneCborx } from "../misc/cloneHelpers";
35
35
  import { formatPercent } from "socket-function/src/formatting/format";
36
- import { addStatPeriodic, onTimeProfile } from "../-0-hooks/hooks";
36
+ import { addStatPeriodic, interceptCalls, onAllPredictionsFinished, onTimeProfile } from "../-0-hooks/hooks";
37
37
 
38
38
  // TODO: Break this into two parts:
39
39
  // 1) Run and get accesses
@@ -1032,7 +1032,11 @@ export class PathValueProxyWatcher {
1032
1032
  }
1033
1033
  };
1034
1034
  watcher.hasAnyUnsyncedAccesses = () => {
1035
- return watcher.pendingUnsyncedAccesses.size > 0 || watcher.pendingUnsyncedParentAccesses.size > 0 || watcher.specialPromiseUnsynced;
1035
+ return (
1036
+ watcher.pendingUnsyncedAccesses.size > 0
1037
+ || watcher.pendingUnsyncedParentAccesses.size > 0
1038
+ || watcher.specialPromiseUnsynced
1039
+ );
1036
1040
  };
1037
1041
 
1038
1042
  function dispose() {
@@ -1110,6 +1114,20 @@ export class PathValueProxyWatcher {
1110
1114
  // to do the permissions checks if they want them
1111
1115
  throw new Error(`Nested synced function calls are not allowed. Call the function directly, or use Querysub.onCommitFinished to wait for the function to finish.`);
1112
1116
  } else if (handling === "after" || handling === undefined) {
1117
+
1118
+ // We need to wait for predictions to finish, otherwise we run into situations
1119
+ // where we call a function which should change a parameter we want to pass
1120
+ // to another function, but because the first call didn't predict, the second
1121
+ // call gets a different values, causing all kinds of issues.
1122
+ if (watcher.pendingCalls.length === 0) {
1123
+ let waitPromise = onAllPredictionsFinished();
1124
+ if (waitPromise) {
1125
+ proxyWatcher.triggerOnPromiseFinish(waitPromise, {
1126
+ waitReason: "Waiting for predictions to finish",
1127
+ });
1128
+ }
1129
+ }
1130
+
1113
1131
  watcher.pendingCalls.push({ call, metadata });
1114
1132
  } else if (handling === "ignore") {
1115
1133
  } else {
@@ -1122,7 +1140,7 @@ export class PathValueProxyWatcher {
1122
1140
  runCodeWithDatabase(proxy, baseFunction)
1123
1141
  );
1124
1142
  },
1125
- });
1143
+ }) as Result;
1126
1144
  }
1127
1145
 
1128
1146
  // We use atomic object read, as our callers don't want a proxy.
@@ -75,9 +75,7 @@ type TypeDefType<T = never, Optional = false> = {
75
75
  /** An object (so not atomic), but optional (so you can delete it), without automatic GCing of deleted objects. */
76
76
  optionalObjectNoGC<ObjectT>(object: ObjectT): TypeDefType<TypeDefToT<ObjectT>, true>;
77
77
 
78
- /** "optionalObject" should usually be used, OR, directly use objects, ex, { a: { value: t.string } }.
79
- * However there are some cases when this is useful.
80
- * - An object (so not atomic)
78
+ /** @deprecated Not really needed, just use an object directly ex, { a: { value: t.string } }. UNLESS this object is optional, then use "optionalObject"
81
79
  */
82
80
  object<ObjectT>(object: ObjectT): TypeDefType<TypeDefToT<ObjectT>, true>;
83
81
 
@@ -15,6 +15,7 @@ import { getPathStr2 } from "../path";
15
15
  import { isNode, sort } from "socket-function/src/misc";
16
16
  import { decodeCborx, encodeCborx } from "../misc/cloneHelpers";
17
17
  import { parseFilterable } from "../misc/filterable";
18
+ import { interceptCalls } from "../-0-hooks/hooks";
18
19
 
19
20
  // NOTE: We could deploy single functions, but... we will almost always be updating all functions at
20
21
  // once, because keeping everything on the same git hash reduces a lot of potential bugs.
@@ -101,13 +102,14 @@ export async function runCall(call: CallSpec, metadata: FunctionMetadata) {
101
102
  return await writeCall.value(call, metadata);
102
103
  }
103
104
 
104
- interface CallInterceptor<T> {
105
+
106
+ export interface CallInterceptor<T> {
105
107
  onCall: (call: CallSpec, metadata: FunctionMetadata) => void;
106
108
  code: () => T;
107
109
  }
108
110
 
109
111
  let curInterceptor: CallInterceptor<unknown> | undefined = undefined;
110
- export function interceptCalls<T>(
112
+ export function interceptCallsBase<T>(
111
113
  interceptor: CallInterceptor<T>
112
114
  ) {
113
115
  let prev = curInterceptor;
@@ -118,6 +120,8 @@ export function interceptCalls<T>(
118
120
  curInterceptor = prev;
119
121
  }
120
122
  }
123
+ interceptCalls.declare(interceptCallsBase);
124
+
121
125
 
122
126
  export function writeFunctionCall(config: {
123
127
  domainName: string;
@@ -279,19 +279,26 @@ export async function waitForImportBlockers() {
279
279
  }
280
280
  }
281
281
 
282
+ function getDirname(fileName: string) {
283
+ fileName = fileName.replaceAll("\\", "/");
284
+ let lastSlash = fileName.lastIndexOf("/");
285
+ if (lastSlash === -1) return "";
286
+ return fileName.slice(0, lastSlash);
287
+ }
288
+
282
289
  // On import every parent folder is set to the spec. For root folders this makes
283
290
  // the spec useless, but for unique folders it will only be set once, making it very useful!
284
291
  // "overlapping" means multiple folders mapped to this spec
285
292
  let folderToSpec = new Map<string, LoadFunctionSpec | "overlapping">();
286
293
  function registerSpec(fileName: string, spec: LoadFunctionSpec | "overlapping") {
287
- let folder = path.dirname(fileName);
294
+ let folder = getDirname(fileName);
288
295
  while (true) {
289
296
  if (folderToSpec.has(folder)) {
290
297
  folderToSpec.set(folder, "overlapping");
291
298
  } else {
292
299
  folderToSpec.set(folder, spec);
293
300
  }
294
- let parentFolder = path.dirname(folder);
301
+ let parentFolder = getDirname(folder);
295
302
  if (parentFolder === folder) {
296
303
  break;
297
304
  }
@@ -300,11 +307,11 @@ function registerSpec(fileName: string, spec: LoadFunctionSpec | "overlapping")
300
307
  }
301
308
 
302
309
  export function getSpecFromModule(module: NodeJS.Module): LoadFunctionSpec | undefined {
303
- let folder = path.dirname(module.filename);
310
+ let folder = getDirname(module.filename);
304
311
  while (true) {
305
312
  let spec = folderToSpec.get(folder);
306
313
  if (spec && typeof spec !== "string") return spec;
307
- let parentFolder = path.dirname(folder);
314
+ let parentFolder = getDirname(folder);
308
315
  if (parentFolder === folder) break;
309
316
  folder = parentFolder;
310
317
  }
@@ -137,6 +137,12 @@ export type PermissionsParameters = {
137
137
  ```
138
138
  * */
139
139
  callerMachineId: string;
140
+
141
+ // IMPORTANT! DO NOT add "matchedValue" here. It is convenient, BUT, it is better for the user
142
+ // to access it themselves using pathWildcards. This makes it more typesafe, allowing
143
+ // them to find all references and find that reference (otherwise the reference will be hidden).
144
+ // - Also, I don't think we could make matchedValue typesafe at all, so it would have to be any,
145
+ // which is terrible.
140
146
  };
141
147
  /** A false of false will deny read permissions, resulting in all reads being given value with a value
142
148
  * of undefined, and a time of 0.
@@ -11,7 +11,7 @@ import { measureBlock, measureCode, measureFnc } from "socket-function/src/profi
11
11
  import { canHaveChildren } from "socket-function/src/types";
12
12
  import { errorify, logErrors } from "../errors";
13
13
  import { cache, lazy } from "socket-function/src/caching";
14
- import { getPathStr1, getPathIndexAssert } from "../path";
14
+ import { getPathStr1, getPathIndexAssert, getPathStr2 } from "../path";
15
15
  import { blue, green, red, yellow } from "socket-function/src/formatting/logColors";
16
16
  import { heapTagObj } from "../diagnostics/heapTag";
17
17
  import { onHotReload } from "socket-function/hot/HotReloadController";
@@ -1342,19 +1342,24 @@ class QRenderClass {
1342
1342
  if (Array.isArray(node)) return 0;
1343
1343
  if (isVNode(node)) {
1344
1344
  if (node.key) {
1345
- return String(node.key);
1345
+ // The type HAS to match as WELL as the type. Otherwise the key can force
1346
+ // spans to match divs, or... differently component types to match, which is bad.
1347
+ return getPathStr2(String(node.key), getSubKey(node));
1346
1348
  }
1347
- // So... if you change the value of a select option, it changes the parent select.value. Which is...
1348
- // odd. We can easily fix this by just using the value as the key, which prevents us from ever
1349
- // changing the .value
1350
- if (node.type === "option") {
1351
- return String(node.props.value);
1352
- }
1353
- let type = node.type;
1354
- if (typeof type === "function") {
1355
- return QRenderClass.componentIdDedupe(type);
1349
+ return getSubKey(node);
1350
+ function getSubKey(node: VirtualDOMElement) {
1351
+ // So... if you change the value of a select option, it changes the parent select.value. Which is...
1352
+ // odd. We can easily fix this by just using the value as the key, which prevents us from ever
1353
+ // changing the .value
1354
+ if (node.type === "option") {
1355
+ return String(node.props.value);
1356
+ }
1357
+ let type = node.type;
1358
+ if (typeof type === "function") {
1359
+ return QRenderClass.componentIdDedupe(type);
1360
+ }
1361
+ return type;
1356
1362
  }
1357
- return type;
1358
1363
  }
1359
1364
  return typeof node;
1360
1365
  }
@@ -1749,7 +1754,7 @@ function updateDOMNodeFields(domNode: DOMNode, vNode: VirtualDOM, prevVNode: Vir
1749
1754
  ready: false,
1750
1755
  wrappedCallback: wrapEventCallback(`${owner.debugName}.eventHandlers.${key}`, name,
1751
1756
  function (this: any, ...args: any[]) {
1752
- if (!eventNode._eventHandlers?.[name].ready && name === "click") {
1757
+ if (name === "click" && !eventNode._eventHandlers?.[name].ready) {
1753
1758
  // UPDATE: ONLY for clicks, otherwise we break blur handlers.
1754
1759
  // IMPORTANT NOTE: IF you are inside of a click handler and add a handler to
1755
1760
  // the parent, the parent's handler will be triggered. An event might
@@ -1763,6 +1768,17 @@ function updateDOMNodeFields(domNode: DOMNode, vNode: VirtualDOM, prevVNode: Vir
1763
1768
  // before we start
1764
1769
  return;
1765
1770
  }
1771
+ // Ignore blurs for disconnected elements. This frequently happens when state changes
1772
+ // inside of a keydown (ex, enter+shift) trigger a complete re-render, which unmounts
1773
+ // an input. Because we already triggered a state change, we usually don't want to also
1774
+ // blur (which will trigger change handlers for inputs).
1775
+ if (name === "blur") {
1776
+ let target = args[0].currentTarget as HTMLElement;
1777
+ if (!target.getAttribute("data-blur-on-unmount") && !target.isConnected) {
1778
+ console.info("Ignoring blur for disconnected element. You can use data-blur-on-unmount to re-enable blurs on this element.", target);
1779
+ return;
1780
+ }
1781
+ }
1766
1782
  let prevEvent = QRenderClass.eventComponentId;
1767
1783
  QRenderClass.eventComponentId = owner.id;
1768
1784
  try {
@@ -21,7 +21,7 @@ import { getCurrentCallAllowUndefined, getCurrentCall, CallSpec, PathFunctionRun
21
21
  import { listenOnDebugger } from "../diagnostics/listenOnDebugger";
22
22
  import { logErrors } from "../errors";
23
23
  import { getLastPathPart, getPathIndexAssert, getPathStr2, hack_setPackedPathSuffix } from "../path";
24
- import { QuerysubController, flushDelayedFunctions, onCallPredict, waitUntilAllPredictionsFinish } from "./QuerysubController";
24
+ import { QuerysubController, anyPredictionsPending, flushDelayedFunctions, onCallPredict, waitUntilAllPredictionsFinish } from "./QuerysubController";
25
25
  import { PermissionsCheck } from "./permissions";
26
26
  import { inlineNestedCalls, syncSchema } from "../3-path-functions/syncSchema";
27
27
  import type { identityStorageKey, IdentityStorageType } from "../-a-auth/certs";
@@ -344,6 +344,11 @@ export class Querysub {
344
344
  public static onCommitFinished(callback: () => void) {
345
345
  proxyWatcher.getTriggeredWatcher().onInnerDisposed.push(callback);
346
346
  }
347
+ public static onCommitFinishedCommit(callback: () => void) {
348
+ this.onCommitFinished(() => {
349
+ Querysub.commit(callback);
350
+ });
351
+ }
347
352
 
348
353
  /** A more powerful version of omCommitFinished, which even waits for call predictions (or tries to).
349
354
  * - Also see afterPredictionsSynced, which runs the callback in a write.
@@ -385,6 +390,14 @@ export class Querysub {
385
390
  * (such as if code is still loading).
386
391
  */
387
392
  public static waitUntilAllPredictionsFinished = () => waitUntilAllPredictionsFinish();
393
+ /** Useful to prevent in-progress state from showing temporarily. */
394
+ public static waitForPredictionsSynced = () => {
395
+ if (anyPredictionsPending()) {
396
+ proxyWatcher.triggerOnPromiseFinish(Querysub.waitUntilAllPredictionsFinished(), {
397
+ waitReason: "Waiting for predictions to finish",
398
+ });
399
+ }
400
+ };
388
401
  public static onCommitPredictFinished = this.onCallPredict;
389
402
 
390
403
  public static getOwnMachineId = getOwnMachineId;
@@ -42,7 +42,7 @@ setFlag(require, "preact", "allowclient", true);
42
42
 
43
43
  import yargs from "yargs";
44
44
  import { mergeFilterables, parseFilterable, serializeFilterable } from "../misc/filterable";
45
- import { isManagementUser } from "../-0-hooks/hooks";
45
+ import { isManagementUser, onAllPredictionsFinished } from "../-0-hooks/hooks";
46
46
  import { isLocal } from "../config";
47
47
 
48
48
  let yargObj = isNodeTrue() && yargs(process.argv)
@@ -157,6 +157,8 @@ let pendingPredictedCalls = new Map<string, {
157
157
  obj: PromiseObj;
158
158
  seqNum: number;
159
159
  }>();
160
+ export const debug_pendingPredictedCalls = pendingPredictedCalls;
161
+
160
162
  export function callWaitOn(callId: string, promise: Promise<unknown>) {
161
163
  let waitObj = pendingPredictedCalls.get(callId);
162
164
  if (!waitObj) return;
@@ -205,13 +207,24 @@ export async function onCallPredict(call: CallSpec | undefined) {
205
207
  if (!call) return;
206
208
  await pendingPredictedCalls.get(call.CallId)?.obj.promise;
207
209
  }
210
+ export function anyPredictionsPending() {
211
+ return Array.from(pendingPredictedCalls.values()).some(obj => !obj.obj.resolved && !obj.obj.rejected);
212
+ }
213
+ export const debug_anyPredictionsPending = anyPredictionsPending;
208
214
  export async function waitUntilAllPredictionsFinish() {
209
- try {
210
- await Promise.allSettled(Array.from(pendingPredictedCalls.values()).map(obj => obj.obj.promise));
211
- } catch {
215
+ while (anyPredictionsPending()) {
216
+ try {
217
+ await Promise.allSettled(Array.from(pendingPredictedCalls.values()).map(obj => obj.obj.promise));
218
+ } catch {
219
+ }
212
220
  }
213
221
  }
214
222
 
223
+ onAllPredictionsFinished.declare(() => {
224
+ if (!anyPredictionsPending()) return undefined;
225
+ return waitUntilAllPredictionsFinish();
226
+ });
227
+
215
228
 
216
229
  export async function flushDelayedFunctions() {
217
230
  await prediction.flushDelayedFunctions();
@@ -21,6 +21,7 @@ import { FunctionMetadata } from "../3-path-functions/syncSchema";
21
21
  import { isNode, nextId, sort } from "socket-function/src/misc";
22
22
  import { getBrowserUrlNode } from "../-f-node-discovery/NodeDiscovery";
23
23
  import { isLocal } from "../config";
24
+ import { onAllPredictionsFinished } from "../-0-hooks/hooks";
24
25
  setFlag(require, "cbor-x", "allowclient", true);
25
26
  const cborEncoder = lazy(() => new cbor.Encoder({ structuredClone: true }));
26
27
 
@@ -98,6 +99,7 @@ export function flushDelayedFunctions() {
98
99
  }
99
100
  }
100
101
 
102
+
101
103
  export const addCall = runInSerial(async function addCall(call: CallSpec, metadata: FunctionMetadata) {
102
104
  const nodeId = await querysubNodeId();
103
105
  if (!nodeId) throw new Error("No querysub node found");
@@ -23,6 +23,8 @@ export type InputProps = (
23
23
  inputRef?: (x: HTMLInputElement | null) => void;
24
24
  /** Don't blur on enter key */
25
25
  noEnterKeyBlur?: boolean;
26
+ /** Don't commit on shift+enter key */
27
+ noEnterKeyCommit?: boolean;
26
28
  noFocusSelect?: boolean;
27
29
  inputKey?: string;
28
30
  fillWidth?: boolean;
@@ -212,7 +214,7 @@ export class Input extends qreact.Component<InputProps> {
212
214
  callback?.(e as unknown as preact.JSX.TargetedInputEvent<HTMLInputElement>);
213
215
  }
214
216
  }
215
- let { noEnterKeyBlur, onInput, onChange } = props;
217
+ let { noEnterKeyBlur, noEnterKeyCommit, onInput, onChange } = props;
216
218
  // Detach from the synced function, to prevent double calls. This is important, as apparently .blur()
217
219
  // synchronously triggers onChange, BUT, only if the input is changing the first time. Which means
218
220
  // if this function reruns, it won't trigger the change again. Detaching it causes any triggered
@@ -236,21 +238,7 @@ export class Input extends qreact.Component<InputProps> {
236
238
  }
237
239
  if (!noEnterKeyBlur && e.code === "Enter" && (!textarea || e.shiftKey || e.ctrlKey)) {
238
240
  e.currentTarget.blur();
239
- } else if (
240
- e.ctrlKey && (
241
- // No need to commit on on paste or cut. If they want to commit, they can commit like they usually
242
- // do. This shortcut only marginally affects a few workflows, and has the potential to break many others,
243
- // so... let's not.
244
- // // Pasting and cutting might not mean commit, but... they probably mean for it to...
245
- // e.code === "KeyV" ||
246
- // e.code === "KeyX" ||
247
-
248
- // Ctrl+enter means "commit"
249
- e.code === "Enter"
250
- )
251
- // Shift+enter means "commit"
252
- || e.shiftKey && e.code === "Enter"
253
- ) {
241
+ } else if (!noEnterKeyCommit && e.code === "Enter" && (e.ctrlKey || e.shiftKey)) {
254
242
  Querysub.serviceWriteDetached(() => {
255
243
  onChange?.(e);
256
244
  });
@@ -137,7 +137,7 @@ export class InputLabel extends qreact.Component<InputLabelProps> {
137
137
  style={style}
138
138
  />;
139
139
  if (props.edit && !this.state.editting) {
140
- input = <span class={css.hbox(2) + " trigger-hover"}>
140
+ input = <span class={css.hbox(2).overflowHidden + " trigger-hover"}>
141
141
  <span class={props.editClass}>
142
142
  {props.editValue ?? props.value}
143
143
  </span>
@@ -27,6 +27,7 @@ export class InputPicker<T> extends qreact.Component<{
27
27
  addPicked: (value: T) => void;
28
28
  removePicked: (value: T) => void;
29
29
  allowNonOptions?: boolean;
30
+ paletteOptions?: boolean;
30
31
  }> {
31
32
  state = {
32
33
  pendingText: "",
@@ -61,31 +62,38 @@ export class InputPicker<T> extends qreact.Component<{
61
62
  pendingMatches = pendingMatches.slice(0, 10);
62
63
  extra -= pendingMatches.length;
63
64
  return (
64
- <div class={css.hbox(10).alignItems("start")}>
65
- {this.props.label}
66
- <Input
67
- value={this.state.pendingText}
68
- hot
69
- alwaysUseLatestValueWhenFocused
70
- onChangeValue={(x) => this.state.pendingText = x}
71
- onFocus={() => this.state.focused = true}
72
- onBlur={() => {
73
- this.state.focused = false;
74
- this.state.pendingText = "";
75
- }}
76
- onKeyDown={e => {
77
- // On tab, add first in pendingMatches
78
- if (e.key === "Tab") {
79
- e.preventDefault();
80
- if (pendingMatches.length > 0) {
81
- this.props.addPicked(pendingMatches[0].value);
82
- this.state.pendingText = "";
65
+ <div class={css.hbox(10).alignItems("start").relative}>
66
+ <div class={css.hbox(10).pad2(0, 3)}>
67
+ <div className={css.flexShrink0}>
68
+ {this.props.label}
69
+ </div>
70
+ <Input
71
+ value={this.state.pendingText}
72
+ hot
73
+ alwaysUseLatestValueWhenFocused
74
+ onChangeValue={(x) => this.state.pendingText = x}
75
+ onFocus={() => this.state.focused = true}
76
+ onBlur={() => {
77
+ this.state.focused = false;
78
+ this.state.pendingText = "";
79
+ }}
80
+ onKeyDown={e => {
81
+ // On enter, add first in pendingMatches
82
+ if (e.key === "Enter") {
83
+ e.preventDefault();
84
+ if (pendingMatches.length > 0) {
85
+ this.props.addPicked(pendingMatches[0].value);
86
+ this.state.pendingText = "";
87
+ }
83
88
  }
84
- }
85
- }}
86
- />
89
+ }}
90
+ />
91
+ </div>
87
92
  {pendingMatches.length > 0 && (
88
- <div class={css.hbox(4).wrap}>
93
+ <div class={
94
+ css.hbox(4).wrap
95
+ + (this.props.paletteOptions && css.absolute.pos(0, "100%").zIndex(1).hsla(0, 0, 0, 0.5).pad2(4))
96
+ }>
89
97
  {pendingMatches.map((option) => (
90
98
  <Button
91
99
  key={`add-${option.matchText}`}
@@ -5,14 +5,22 @@ import { formatNumber, formatTime } from "socket-function/src/formatting/format"
5
5
 
6
6
  export class UnsyncedIndicator extends qreact.Component {
7
7
  lastSynced = Date.now();
8
+ wasSyncedLast = false;
8
9
  render() {
9
10
  let unsynced = Querysub.watchUnsyncedComponents();
10
11
  let unsyncedCount = unsynced.size;
11
12
  if (unsyncedCount === 0) {
12
13
  this.lastSynced = Date.now();
14
+ this.wasSyncedLast = true;
13
15
  return undefined;
14
16
  }
15
- let timeSinceLastSync = Date.now() - this.lastSynced;
17
+ // If we were synced in the last render, then we actually were just synced, so...
18
+ // set synced to now.
19
+ if (this.wasSyncedLast) {
20
+ this.wasSyncedLast = false;
21
+ this.lastSynced = Date.now();
22
+ }
23
+ let timeSinceLastSync = Querysub.timeDelayed(2000) - this.lastSynced;
16
24
  if (timeSinceLastSync < 1500) {
17
25
  return undefined;
18
26
  }
package/src/promise.ts CHANGED
@@ -9,7 +9,7 @@ export class PromiseObj<T = void> {
9
9
  this.resolve = resolve;
10
10
  this.reject = reject;
11
11
  });
12
- this.promise.finally(() => this.resolved = true);
12
+ void this.promise.finally(() => this.resolved = true);
13
13
  this.promise.catch(() => this.rejected = true);
14
14
  }
15
15
  }