@buoy-gg/time-machine 7.0.20 → 7.0.22

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.
Files changed (40) hide show
  1. package/lib/commonjs/components/TimeMachineBody.js +103 -8
  2. package/lib/commonjs/components/TimeMachineBody.js.map +1 -1
  3. package/lib/commonjs/index.js +55 -0
  4. package/lib/commonjs/index.js.map +1 -1
  5. package/lib/commonjs/store/routeRestore.js +188 -0
  6. package/lib/commonjs/store/routeRestore.js.map +1 -0
  7. package/lib/commonjs/store/snapshotVault.js.map +1 -1
  8. package/lib/commonjs/store/timeMachineStore.js +41 -3
  9. package/lib/commonjs/store/timeMachineStore.js.map +1 -1
  10. package/lib/commonjs/sync/timeMachineSyncAdapter.js +18 -4
  11. package/lib/commonjs/sync/timeMachineSyncAdapter.js.map +1 -1
  12. package/lib/module/components/TimeMachineBody.js +104 -9
  13. package/lib/module/components/TimeMachineBody.js.map +1 -1
  14. package/lib/module/index.js +11 -0
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/module/store/routeRestore.js +176 -0
  17. package/lib/module/store/routeRestore.js.map +1 -0
  18. package/lib/module/store/snapshotVault.js.map +1 -1
  19. package/lib/module/store/timeMachineStore.js +41 -3
  20. package/lib/module/store/timeMachineStore.js.map +1 -1
  21. package/lib/module/sync/timeMachineSyncAdapter.js +18 -4
  22. package/lib/module/sync/timeMachineSyncAdapter.js.map +1 -1
  23. package/lib/typescript/components/TimeMachineBody.d.ts.map +1 -1
  24. package/lib/typescript/index.d.ts +2 -1
  25. package/lib/typescript/index.d.ts.map +1 -1
  26. package/lib/typescript/store/routeRestore.d.ts +51 -0
  27. package/lib/typescript/store/routeRestore.d.ts.map +1 -0
  28. package/lib/typescript/store/snapshotVault.d.ts +7 -0
  29. package/lib/typescript/store/snapshotVault.d.ts.map +1 -1
  30. package/lib/typescript/store/timeMachineStore.d.ts +29 -1
  31. package/lib/typescript/store/timeMachineStore.d.ts.map +1 -1
  32. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts +8 -0
  33. package/lib/typescript/sync/timeMachineSyncAdapter.d.ts.map +1 -1
  34. package/package.json +12 -8
  35. package/src/components/TimeMachineBody.tsx +146 -10
  36. package/src/index.tsx +22 -0
  37. package/src/store/routeRestore.ts +194 -0
  38. package/src/store/snapshotVault.ts +7 -0
  39. package/src/store/timeMachineStore.ts +60 -1
  40. package/src/sync/timeMachineSyncAdapter.ts +27 -4
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Route capture & restore for Time Machine.
3
+ *
4
+ * A restore point is only half the story: state without the screen you were
5
+ * on drops you into the right data on the wrong page. Every snapshot records
6
+ * the route it was captured on, and a restore can navigate back to it.
7
+ *
8
+ * Two navigation paths, because a "reload" restore kills the JS realm:
9
+ * - LIVE restore: navigate immediately once the sources are applied.
10
+ * - RELOAD restore: persist the route BEFORE the reload is scheduled, then
11
+ * {@link armPendingRouteRestore} picks it up on the next boot, waits for the
12
+ * router to come online, and navigates.
13
+ *
14
+ * Everything here is best-effort: apps without expo-router capture no route,
15
+ * so the UI simply never offers the option.
16
+ */
17
+ import {
18
+ getSafeCurrentRoute,
19
+ getSafeRouter,
20
+ persistentStorage,
21
+ type SafeRouteLocation,
22
+ } from "@buoy-gg/shared-ui";
23
+
24
+ /**
25
+ * The route a snapshot was captured on. Only the URL is stored — imperative
26
+ * params passed as objects (`navigate(path, { user })`) are not part of a
27
+ * route and are not restored.
28
+ */
29
+ export interface SnapshotRoute {
30
+ /** Pathname with dynamic segments resolved, e.g. `/pokemon/25`. */
31
+ pathname: string;
32
+ /** What we navigate to — pathname plus search params. */
33
+ href: string;
34
+ }
35
+
36
+ /** How a restore's route leg ended, reported back in the RestoreOutcome. */
37
+ export type RouteRestoreStatus =
38
+ | "navigated"
39
+ | "deferred"
40
+ | "failed"
41
+ | "unavailable";
42
+
43
+ export interface RouteRestoreResult {
44
+ pathname: string;
45
+ href: string;
46
+ status: RouteRestoreStatus;
47
+ /** Present when status is "failed" / "unavailable". */
48
+ error?: string;
49
+ }
50
+
51
+ /**
52
+ * Same `@react_buoy` family as the snapshot vault, so every tool's
53
+ * isDevToolsStorageKey filter (and therefore every wipe/restore) leaves it
54
+ * alone — a pending route must survive the storage restore that precedes the
55
+ * reload it's waiting on.
56
+ */
57
+ const PENDING_KEY = "@react_buoy_time_machine:pending-route";
58
+
59
+ /**
60
+ * A pending route older than this belongs to a reload that never happened
61
+ * (the app was killed, the user moved on). Boot-time navigation into a screen
62
+ * someone left minutes ago is worse than doing nothing.
63
+ */
64
+ export const PENDING_ROUTE_TTL_MS = 60_000;
65
+
66
+ /** How long to wait at boot for the router to come online. */
67
+ const ROUTER_READY_TIMEOUT_MS = 8_000;
68
+
69
+ /** Settle window after the router appears, so the initial route mounts first. */
70
+ const ROUTER_SETTLE_MS = 250;
71
+
72
+ interface PendingRoute extends SnapshotRoute {
73
+ savedAt: number;
74
+ snapshotName?: string;
75
+ }
76
+
77
+ function toSnapshotRoute(location: SafeRouteLocation): SnapshotRoute {
78
+ return { pathname: location.pathname, href: location.href };
79
+ }
80
+
81
+ /** The route to stamp on a snapshot being captured, or null when unavailable. */
82
+ export function captureCurrentRoute(): SnapshotRoute | null {
83
+ const current = getSafeCurrentRoute();
84
+ return current ? toSnapshotRoute(current) : null;
85
+ }
86
+
87
+ /** The live route, for "you're already here" checks in the UI. */
88
+ export function currentRoute(): SnapshotRoute | null {
89
+ return captureCurrentRoute();
90
+ }
91
+
92
+ /** True when imperative navigation is possible on this device. */
93
+ export function canNavigate(): boolean {
94
+ return getSafeRouter() != null;
95
+ }
96
+
97
+ /**
98
+ * Navigate to a captured route now. Uses `navigate` (not `push`) so restoring
99
+ * the same route twice doesn't stack duplicate screens.
100
+ */
101
+ export function navigateToRoute(route: SnapshotRoute): RouteRestoreResult {
102
+ const base = { pathname: route.pathname, href: route.href };
103
+ const router = getSafeRouter();
104
+ if (!router?.navigate) {
105
+ return {
106
+ ...base,
107
+ status: "unavailable",
108
+ error: "expo-router is not available on this device",
109
+ };
110
+ }
111
+ try {
112
+ router.navigate(route.href);
113
+ return { ...base, status: "navigated" };
114
+ } catch (e) {
115
+ return { ...base, status: "failed", error: String(e) };
116
+ }
117
+ }
118
+
119
+ // ── Pending route (survives the reload) ──────────────────────────────────────
120
+
121
+ export async function savePendingRoute(
122
+ route: SnapshotRoute,
123
+ snapshotName?: string
124
+ ): Promise<void> {
125
+ const pending: PendingRoute = {
126
+ ...route,
127
+ savedAt: Date.now(),
128
+ ...(snapshotName ? { snapshotName } : {}),
129
+ };
130
+ await persistentStorage.setItem(PENDING_KEY, JSON.stringify(pending));
131
+ }
132
+
133
+ export async function clearPendingRoute(): Promise<void> {
134
+ try {
135
+ await persistentStorage.removeItem(PENDING_KEY);
136
+ } catch {
137
+ // best-effort — a stale key is dropped by the TTL check anyway
138
+ }
139
+ }
140
+
141
+ async function loadPendingRoute(): Promise<PendingRoute | null> {
142
+ try {
143
+ const raw = await persistentStorage.getItem(PENDING_KEY);
144
+ if (!raw) return null;
145
+ const parsed = JSON.parse(raw) as Partial<PendingRoute>;
146
+ if (typeof parsed?.href !== "string" || !parsed.href) return null;
147
+ if (typeof parsed.savedAt !== "number") return null;
148
+ return {
149
+ pathname:
150
+ typeof parsed.pathname === "string" ? parsed.pathname : parsed.href,
151
+ href: parsed.href,
152
+ savedAt: parsed.savedAt,
153
+ snapshotName:
154
+ typeof parsed.snapshotName === "string" ? parsed.snapshotName : undefined,
155
+ };
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ let armed = false;
162
+
163
+ /**
164
+ * Boot-time consumer of a pending route: called once when the package loads
165
+ * (core requires it while mounting the floating menu), it navigates to the
166
+ * route a reload-mode restore asked for and clears the slot.
167
+ *
168
+ * Idempotent and self-limiting — the slot is cleared before navigating, so a
169
+ * route that crashes the app on arrival can't put it in a boot loop.
170
+ */
171
+ export function armPendingRouteRestore(): void {
172
+ if (armed) return;
173
+ armed = true;
174
+ void (async () => {
175
+ const pending = await loadPendingRoute();
176
+ if (!pending) return;
177
+ // Clear FIRST: whatever happens next, this route is spent.
178
+ await clearPendingRoute();
179
+ if (Date.now() - pending.savedAt > PENDING_ROUTE_TTL_MS) return;
180
+
181
+ const deadline = Date.now() + ROUTER_READY_TIMEOUT_MS;
182
+ while (!canNavigate() && Date.now() < deadline) {
183
+ await new Promise((r) => setTimeout(r, 100));
184
+ }
185
+ if (!canNavigate()) return;
186
+ await new Promise((r) => setTimeout(r, ROUTER_SETTLE_MS));
187
+ navigateToRoute({ pathname: pending.pathname, href: pending.href });
188
+ })();
189
+ }
190
+
191
+ /** @internal test seam — re-arms the boot-time consumer. */
192
+ export function resetPendingRouteArm(): void {
193
+ armed = false;
194
+ }
@@ -18,6 +18,7 @@ import {
18
18
  persistentStorage,
19
19
  type SuperJSONResult,
20
20
  } from "@buoy-gg/shared-ui";
21
+ import type { SnapshotRoute } from "./routeRestore";
21
22
 
22
23
  const INDEX_KEY = "@react_buoy_time_machine:index";
23
24
  const SNAP_PREFIX = "@react_buoy_time_machine:snap:";
@@ -43,6 +44,12 @@ export interface SnapshotMeta {
43
44
  * means just my cart". Absent = full restore.
44
45
  */
45
46
  scope?: string[];
47
+ /**
48
+ * The route the app was on when this snapshot was captured. Absent when the
49
+ * app has no expo-router (nothing to return to). A restore can navigate back
50
+ * to it — see routeRestore.ts.
51
+ */
52
+ route?: SnapshotRoute;
46
53
  /** Source ids captured, with their capture warnings. */
47
54
  sources: Record<string, SnapshotSourceMeta>;
48
55
  }
@@ -36,6 +36,12 @@ import {
36
36
  type SnapshotMeta,
37
37
  type StoredSnapshot,
38
38
  } from "./snapshotVault";
39
+ import {
40
+ captureCurrentRoute,
41
+ navigateToRoute,
42
+ savePendingRoute,
43
+ type RouteRestoreResult,
44
+ } from "./routeRestore";
39
45
 
40
46
  export const RESTORE_ORDER = ["storage", "redux", "zustand", "jotai", "query"];
41
47
 
@@ -49,6 +55,21 @@ export interface RestoreOutcome {
49
55
  /** True when a JS reload was scheduled as part of this restore. */
50
56
  willReload: boolean;
51
57
  results: Record<string, StateSnapshotRestoreResult>;
58
+ /**
59
+ * Present when the caller asked to also return to the snapshot's route.
60
+ * "deferred" means the navigation is queued for the post-reload boot.
61
+ */
62
+ route?: RouteRestoreResult;
63
+ }
64
+
65
+ /** Extra knobs a restore accepts beyond mode + exclusions. */
66
+ export interface RestoreOptions {
67
+ /**
68
+ * Also navigate back to the route the snapshot was captured on. Ignored for
69
+ * snapshots with no captured route (and for baselines, which wipe to the
70
+ * app's own start route).
71
+ */
72
+ restoreRoute?: boolean;
52
73
  }
53
74
 
54
75
  /** Provider capability row as mirrored to a remote dashboard. */
@@ -92,6 +113,18 @@ export interface TimeMachineState {
92
113
  * registry, and every operation forwards over the socket.
93
114
  */
94
115
  remoteProviders: RemoteProviderStatus[] | null;
116
+ /**
117
+ * Route capture/restore availability, mirrored from the device's payload
118
+ * (Buoy Desktop). Null on-device, where the live router is probed directly.
119
+ */
120
+ remoteRoute: RouteToolStatus | null;
121
+ }
122
+
123
+ /** Whether this device can capture and return to routes, and where it is now. */
124
+ export interface RouteToolStatus {
125
+ available: boolean;
126
+ /** The route the app is on right now, when one can be read. */
127
+ current?: string;
95
128
  }
96
129
 
97
130
  type RemoteHandler = (
@@ -119,6 +152,7 @@ class TimeMachineStore {
119
152
  lastOutcome: null,
120
153
  preview: null,
121
154
  remoteProviders: null,
155
+ remoteRoute: null,
122
156
  };
123
157
  /** Monotonic token — a preview build checks it after every await and
124
158
  * abandons itself when a newer build (or clearPreview) supersedes it. */
@@ -166,12 +200,14 @@ class TimeMachineStore {
166
200
  busy?: TimeMachineState["busy"];
167
201
  lastOutcome?: RestoreOutcome | null;
168
202
  providers?: RemoteProviderStatus[];
203
+ route?: RouteToolStatus;
169
204
  }): void {
170
205
  this.setState({
171
206
  snapshots: payload.snapshots ?? [],
172
207
  busy: payload.busy ?? null,
173
208
  lastOutcome: payload.lastOutcome ?? null,
174
209
  remoteProviders: payload.providers ?? [],
210
+ remoteRoute: payload.route ?? { available: false },
175
211
  hasLoaded: true,
176
212
  });
177
213
  }
@@ -204,12 +240,15 @@ class TimeMachineStore {
204
240
  };
205
241
  }
206
242
  }
243
+ // Where the app was when this state was true — restoring can return here.
244
+ const route = captureCurrentRoute();
207
245
  const snapshot: StoredSnapshot = {
208
246
  meta: {
209
247
  id: nextId(),
210
248
  name: name.trim() || "Untitled snapshot",
211
249
  createdAt: Date.now(),
212
250
  sizeBytes: 0,
251
+ ...(route ? { route } : {}),
213
252
  sources,
214
253
  },
215
254
  data,
@@ -259,13 +298,15 @@ class TimeMachineStore {
259
298
  async restore(
260
299
  id: string,
261
300
  mode: RestoreMode,
262
- excludeKeys?: readonly string[]
301
+ excludeKeys?: readonly string[],
302
+ options?: RestoreOptions
263
303
  ): Promise<RestoreOutcome> {
264
304
  if (remoteHandler) {
265
305
  const outcome = (await remoteHandler("restore", {
266
306
  id,
267
307
  mode,
268
308
  excludeKeys,
309
+ restoreRoute: options?.restoreRoute,
269
310
  })) as RestoreOutcome;
270
311
  this.setState({ lastOutcome: outcome });
271
312
  return outcome;
@@ -330,6 +371,8 @@ class TimeMachineStore {
330
371
  ? splitCompound(stored.meta.scope)
331
372
  : null;
332
373
 
374
+ const wantsRoute = options?.restoreRoute === true && !!stored.meta.route;
375
+
333
376
  if (mode === "reload") {
334
377
  // Storage only; everything in-memory rebuilds after the reload.
335
378
  if (
@@ -343,6 +386,17 @@ class TimeMachineStore {
343
386
  includeBySource?.get("storage")
344
387
  );
345
388
  }
389
+ // Hand the route to the NEXT boot — this realm is about to die, so the
390
+ // slot must be written before the reload is armed.
391
+ if (wantsRoute) {
392
+ const route = stored.meta.route!;
393
+ try {
394
+ await savePendingRoute(route, stored.meta.name);
395
+ outcome.route = { ...route, status: "deferred" };
396
+ } catch (e) {
397
+ outcome.route = { ...route, status: "failed", error: String(e) };
398
+ }
399
+ }
346
400
  outcome.willReload = this.scheduleReload("buoy time-machine restore");
347
401
  this.setState({ lastOutcome: outcome });
348
402
  return outcome;
@@ -358,6 +412,10 @@ class TimeMachineStore {
358
412
  includeBySource?.get(sid)
359
413
  );
360
414
  }
415
+ // Navigate LAST — the screen we land on should read the restored state.
416
+ if (wantsRoute) {
417
+ outcome.route = navigateToRoute(stored.meta.route!);
418
+ }
361
419
  this.setState({ lastOutcome: outcome });
362
420
  return outcome;
363
421
  } finally {
@@ -686,6 +744,7 @@ export function replaceTimeMachineFromSnapshot(payload: {
686
744
  busy?: TimeMachineState["busy"];
687
745
  lastOutcome?: RestoreOutcome | null;
688
746
  providers?: RemoteProviderStatus[];
747
+ route?: RouteToolStatus;
689
748
  }): void {
690
749
  timeMachineStore.replaceFromSnapshot(payload);
691
750
  }
@@ -23,6 +23,7 @@ import {
23
23
  type RestoreMode,
24
24
  } from "../store/timeMachineStore";
25
25
  import { loadSnapshot } from "../store/snapshotVault";
26
+ import { canNavigate, currentRoute } from "../store/routeRestore";
26
27
 
27
28
  interface CaptureParams {
28
29
  name?: string;
@@ -33,6 +34,8 @@ interface RestoreParams {
33
34
  mode?: RestoreMode;
34
35
  /** Compound "sourceId::itemKey" strings — items the caller opted out of. */
35
36
  excludeKeys?: string[];
37
+ /** Also navigate back to the route the snapshot was captured on. */
38
+ restoreRoute?: boolean;
36
39
  }
37
40
  interface IdParams {
38
41
  id: string;
@@ -61,11 +64,24 @@ function providerStatuses() {
61
64
 
62
65
  function metadata() {
63
66
  const { snapshots, busy, lastOutcome } = timeMachineStore.getState();
64
- return { snapshots, busy, lastOutcome, providers: providerStatuses() };
67
+ const current = currentRoute();
68
+ return {
69
+ snapshots,
70
+ busy,
71
+ lastOutcome,
72
+ providers: providerStatuses(),
73
+ // Route capture/restore is a device capability, not a snapshot provider —
74
+ // a dashboard has no router of its own to probe, so it's mirrored here.
75
+ route: {
76
+ available: canNavigate(),
77
+ ...(current ? { current: current.href } : {}),
78
+ },
79
+ };
65
80
  }
66
81
 
67
82
  export const timeMachineSyncAdapter = {
68
- version: 1,
83
+ // v2 adds `route` (device route capability) and `snapshots[].route`.
84
+ version: 2,
69
85
  getSnapshot: () => metadata(),
70
86
  subscribe: (onChange: () => void) => {
71
87
  const unsubStore = timeMachineStore.subscribe(onChange);
@@ -96,9 +112,16 @@ export const timeMachineSyncAdapter = {
96
112
  * persisted storage then reloads the JS bundle (always the path for
97
113
  * baseline snapshots). */
98
114
  restore: async (params: unknown) => {
99
- const { id, mode = "live", excludeKeys } = (params as RestoreParams) ?? {};
115
+ const {
116
+ id,
117
+ mode = "live",
118
+ excludeKeys,
119
+ restoreRoute,
120
+ } = (params as RestoreParams) ?? {};
100
121
  if (!id) throw new Error("restore requires a snapshot `id`");
101
- return await timeMachineStore.restore(id, mode, excludeKeys);
122
+ return await timeMachineStore.restore(id, mode, excludeKeys, {
123
+ restoreRoute,
124
+ });
102
125
  },
103
126
 
104
127
  delete: async (params: unknown) => {