@omercnet/paseo-shared-browser 1.1.0-next.210.1 → 1.1.0-next.211.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.
@@ -45,6 +45,7 @@ import {
45
45
  sendBrowserInputRpc,
46
46
  toViewportPoint,
47
47
  } from "../shared/browser";
48
+ import { FrameLifecycle } from "./frame-lifecycle";
48
49
 
49
50
  const SPACE = {
50
51
  xxs: 2,
@@ -989,14 +990,15 @@ export function SharedBrowserPanel({
989
990
  const mountedRef = useRef(false);
990
991
  const activeViewerTokenRef = useRef<string | null>(null);
991
992
  const stateRef = useRef<BrowserState | null>(null);
992
- const frameRef = useRef<BrowserFrame | null>(null);
993
+ const lifecycleRef = useRef(new FrameLifecycle());
994
+ const lifecycle = lifecycleRef.current;
993
995
  const lastPointRef = useRef<{ x: number; y: number } | null>(null);
994
996
  const dragStartRef = useRef<{ x: number; y: number } | null>(null);
995
- const mutationEpochRef = useRef(0);
996
997
  const captureInFlightRef = useRef(false);
997
998
 
998
999
  const [state, setState] = useState<BrowserState | null>(null);
999
1000
  const [frame, setFrame] = useState<BrowserFrame | null>(null);
1001
+ const [inputBusy, setInputBusy] = useState(false);
1000
1002
  const [controlToken, setControlToken] = useState<string | null>(null);
1001
1003
  const [operationError, setOperationError] = useState<string | null>(null);
1002
1004
  const [imageError, setImageError] = useState(false);
@@ -1024,7 +1026,7 @@ export function SharedBrowserPanel({
1024
1026
  const acceptState = useCallback((next: BrowserState) => {
1025
1027
  const previous = stateRef.current;
1026
1028
  if (previous && !isBrowserStateCurrent(previous, next)) return false;
1027
- const currentFrame = frameRef.current;
1029
+ const currentFrame = lifecycle.frame;
1028
1030
  if (previous && didBrowserRuntimeRestart(previous, next)) {
1029
1031
  setRuntimeNotice(
1030
1032
  "Browser restarted. The preserved viewer connection now targets the new runtime.",
@@ -1032,7 +1034,7 @@ export function SharedBrowserPanel({
1032
1034
  setControlToken(null);
1033
1035
  }
1034
1036
  if (currentFrame && !isFrameCurrent(currentFrame, next)) {
1035
- frameRef.current = null;
1037
+ lifecycle.drop();
1036
1038
  setFrame(null);
1037
1039
  lastPointRef.current = null;
1038
1040
  }
@@ -1074,9 +1076,10 @@ export function SharedBrowserPanel({
1074
1076
 
1075
1077
  useEffect(() => {
1076
1078
  stateRef.current = null;
1077
- frameRef.current = null;
1079
+ lifecycle.reset();
1078
1080
  setState(null);
1079
1081
  setFrame(null);
1082
+ setInputBusy(false);
1080
1083
  setControlToken(null);
1081
1084
  setOperationError(null);
1082
1085
  setRuntimeNotice(null);
@@ -1086,8 +1089,8 @@ export function SharedBrowserPanel({
1086
1089
  queryKey: ["shared-browser", "capture", viewerToken],
1087
1090
  queryFn: async () => {
1088
1091
  if (!viewerToken) throw new Error("The browser viewer is not attached.");
1089
- const mutationEpoch = mutationEpochRef.current;
1090
- const knownFrame = frameRef.current;
1092
+ const mutationEpoch = lifecycle.epoch;
1093
+ const knownFrame = lifecycle.frame;
1091
1094
  captureInFlightRef.current = true;
1092
1095
  try {
1093
1096
  const result = await captureBrowser({
@@ -1113,17 +1116,14 @@ export function SharedBrowserPanel({
1113
1116
 
1114
1117
  useEffect(() => {
1115
1118
  const result = captureQuery.data;
1116
- if (
1117
- !result ||
1118
- result.mutationEpoch !== mutationEpochRef.current ||
1119
- !acceptState(result.state)
1120
- ) {
1119
+ if (!result || !lifecycle.isCurrent(result.mutationEpoch) || !acceptState(result.state)) {
1121
1120
  return;
1122
1121
  }
1123
1122
  if (result.frame && isFrameCurrent(result.frame, result.state)) {
1124
- frameRef.current = result.frame;
1123
+ lifecycle.accept(result.mutationEpoch, result.frame);
1125
1124
  setImageError(false);
1126
- setFrame(result.frame);
1125
+ setFrame(lifecycle.visible);
1126
+ setInputBusy(lifecycle.busy);
1127
1127
  }
1128
1128
  }, [acceptState, captureQuery.data]);
1129
1129
 
@@ -1212,6 +1212,9 @@ export function SharedBrowserPanel({
1212
1212
  onSuccess: (result) => mutationSucceeded(result.state),
1213
1213
  onError: mutationFailed,
1214
1214
  });
1215
+ const settleInput = () => {
1216
+ lifecycle.settle();
1217
+ };
1215
1218
  const inputMutation = useMutation({
1216
1219
  mutationFn: sendBrowserInput,
1217
1220
  retry: false,
@@ -1220,9 +1223,13 @@ export function SharedBrowserPanel({
1220
1223
  const sentText = variables.event.text;
1221
1224
  setTypeDraft((current) => (current === sentText ? "" : current));
1222
1225
  }
1226
+ settleInput();
1223
1227
  mutationSucceeded(result.state);
1224
1228
  },
1225
- onError: mutationFailed,
1229
+ onError: (error) => {
1230
+ settleInput();
1231
+ mutationFailed(error);
1232
+ },
1226
1233
  });
1227
1234
 
1228
1235
  const anyMutationPending =
@@ -1234,7 +1241,8 @@ export function SharedBrowserPanel({
1234
1241
  inputMutation.isPending;
1235
1242
  const canControl = Boolean(viewerToken && controlToken && state?.controller === "self");
1236
1243
  const currentFrame = frame && state && isFrameCurrent(frame, state) ? frame : null;
1237
- const canSendInput = canControl && Boolean(currentFrame) && !inputMutation.isPending;
1244
+ const canSendInput =
1245
+ canControl && Boolean(currentFrame) && !inputMutation.isPending && !inputBusy;
1238
1246
 
1239
1247
  const frameUri = useMemo(
1240
1248
  () => (currentFrame ? `data:${currentFrame.mimeType};base64,${currentFrame.dataBase64}` : null),
@@ -1284,7 +1292,7 @@ export function SharedBrowserPanel({
1284
1292
  const inputContext = useCallback(() => {
1285
1293
  const context = controlContext();
1286
1294
  const current = stateRef.current;
1287
- const targetFrame = frameRef.current;
1295
+ const targetFrame = lifecycle.frame;
1288
1296
  if (!context || !current || !targetFrame || !isFrameCurrent(targetFrame, current)) return null;
1289
1297
  return {
1290
1298
  ...context,
@@ -1317,12 +1325,13 @@ export function SharedBrowserPanel({
1317
1325
 
1318
1326
  const sendEvent = useCallback(
1319
1327
  (event: BrowserInputEvent) => {
1328
+ if (lifecycle.busy) return;
1320
1329
  const context = requireInputContext();
1321
- if (!context || inputMutation.isPending) return;
1322
- mutationEpochRef.current += 1;
1330
+ if (!context || !lifecycle.begin()) return;
1331
+ setInputBusy(true);
1323
1332
  inputMutation.mutate({ ...context, event });
1324
1333
  },
1325
- [inputMutation, requireInputContext],
1334
+ [inputMutation, lifecycle, requireInputContext],
1326
1335
  );
1327
1336
 
1328
1337
  const pointFromEvent = useCallback(
@@ -1402,7 +1411,7 @@ export function SharedBrowserPanel({
1402
1411
  const takeControl = useCallback(
1403
1412
  (takeover: boolean) => {
1404
1413
  if (!viewerToken || acquireMutation.isPending) return;
1405
- mutationEpochRef.current += 1;
1414
+ lifecycle.bump();
1406
1415
  acquireMutation.mutate({ viewerToken, takeover });
1407
1416
  },
1408
1417
  [acquireMutation, viewerToken],
@@ -1417,7 +1426,7 @@ export function SharedBrowserPanel({
1417
1426
  ) {
1418
1427
  return;
1419
1428
  }
1420
- mutationEpochRef.current += 1;
1429
+ lifecycle.bump();
1421
1430
  releaseMutation.mutate({ viewerToken: viewer, controlToken });
1422
1431
  }, [controlToken, releaseMutation]);
1423
1432
 
@@ -1431,11 +1440,11 @@ export function SharedBrowserPanel({
1431
1440
  setOperationError("Enter an address to navigate.");
1432
1441
  return;
1433
1442
  }
1434
- mutationEpochRef.current += 1;
1443
+ lifecycle.bump();
1435
1444
  navigateMutation.mutate({ ...context, action: { kind: "goto", url: nextUrl } });
1436
1445
  return;
1437
1446
  }
1438
- mutationEpochRef.current += 1;
1447
+ lifecycle.bump();
1439
1448
  navigateMutation.mutate({ ...context, action: { kind: action } });
1440
1449
  },
1441
1450
  [navigateMutation, requireControlContext],
@@ -1459,7 +1468,7 @@ export function SharedBrowserPanel({
1459
1468
  );
1460
1469
  return;
1461
1470
  }
1462
- mutationEpochRef.current += 1;
1471
+ lifecycle.bump();
1463
1472
  resizeMutation.mutate({ ...context, viewport: { width, height } });
1464
1473
  }, [requireControlContext, resizeMutation, viewportHeight, viewportWidth]);
1465
1474
 
@@ -1467,7 +1476,7 @@ export function SharedBrowserPanel({
1467
1476
  (presetId: DevicePresetId) => {
1468
1477
  const context = requireControlContext();
1469
1478
  if (!context || deviceMutation.isPending) return;
1470
- mutationEpochRef.current += 1;
1479
+ lifecycle.bump();
1471
1480
  setDevicePickerOpen(false);
1472
1481
  deviceMutation.mutate({ ...context, presetId });
1473
1482
  },
@@ -1498,7 +1507,7 @@ export function SharedBrowserPanel({
1498
1507
 
1499
1508
  const reconnect = useCallback(() => {
1500
1509
  if (attachQuery.isFetching) return;
1501
- mutationEpochRef.current += 1;
1510
+ lifecycle.bump();
1502
1511
  setReconnecting(true);
1503
1512
  setControlToken(null);
1504
1513
  setOperationError(null);
@@ -1703,7 +1712,7 @@ export function SharedBrowserPanel({
1703
1712
  }
1704
1713
  accessibilityRole="image"
1705
1714
  onError={() => {
1706
- frameRef.current = null;
1715
+ lifecycle.frame = null;
1707
1716
  setImageError(true);
1708
1717
  void captureQuery.refetch({ cancelRefetch: false });
1709
1718
  }}
@@ -0,0 +1,65 @@
1
+ import type { BrowserFrame } from "../shared/browser";
2
+
3
+ /**
4
+ * Tracks which captured frame input may target. After an input is submitted
5
+ * and again when it settles, the actionable frame is dropped and the epoch
6
+ * advances, so captures that began earlier are ignored. Input stays blocked
7
+ * until a capture taken after settlement is accepted. The last accepted image
8
+ * stays visible meanwhile.
9
+ */
10
+ export class FrameLifecycle {
11
+ epoch = 0;
12
+ /** Frame input may target; null while a fresh capture is awaited. */
13
+ frame: BrowserFrame | null = null;
14
+ /** Last accepted frame, kept on screen while input is blocked. */
15
+ visible: BrowserFrame | null = null;
16
+ private gate: "idle" | "pending" | "refreshing" = "idle";
17
+
18
+ get busy(): boolean {
19
+ return this.gate !== "idle";
20
+ }
21
+
22
+ bump(): void {
23
+ this.epoch += 1;
24
+ }
25
+
26
+ isCurrent(captureEpoch: number): boolean {
27
+ return captureEpoch === this.epoch;
28
+ }
29
+
30
+ /** Claims the input slot; false if an input is already in flight or awaiting a fresh frame. */
31
+ begin(): boolean {
32
+ if (this.busy) return false;
33
+ this.gate = "pending";
34
+ this.frame = null;
35
+ this.bump();
36
+ return true;
37
+ }
38
+
39
+ /** Called when an input succeeds or fails. Never replays the action. */
40
+ settle(): void {
41
+ this.frame = null;
42
+ this.bump();
43
+ this.gate = "refreshing";
44
+ }
45
+
46
+ /** Accepts a capture started at `captureEpoch`; false if it predates the latest boundary. */
47
+ accept(captureEpoch: number, frame: BrowserFrame): boolean {
48
+ if (!this.isCurrent(captureEpoch)) return false;
49
+ this.frame = frame;
50
+ this.visible = frame;
51
+ if (this.gate === "refreshing") this.gate = "idle";
52
+ return true;
53
+ }
54
+
55
+ /** Forgets all frames, e.g. when the session no longer matches them. */
56
+ drop(): void {
57
+ this.frame = null;
58
+ this.visible = null;
59
+ }
60
+
61
+ reset(): void {
62
+ this.drop();
63
+ this.gate = "idle";
64
+ }
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-shared-browser",
3
- "version": "1.1.0-next.210.1",
3
+ "version": "1.1.0-next.211.1",
4
4
  "description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
5
5
  "keywords": [
6
6
  "browser",
@@ -43,8 +43,8 @@
43
43
  "check:write": "biome check --write .",
44
44
  "typecheck": "tsc --noEmit",
45
45
  "prepare:runtime": "node scripts/prepare-runtime.mjs",
46
- "test": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts",
47
- "test:unit": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts",
46
+ "test": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts",
47
+ "test:unit": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts",
48
48
  "test:smoke": "vitest run tests/browser.smoke.test.ts --testTimeout=30000"
49
49
  },
50
50
  "dependencies": {