@lotics/app-sdk 0.21.0 → 0.23.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.
@@ -2,7 +2,15 @@ import type { AppWorkflows, AppQueries } from "./types.js";
2
2
  import type { ResolvedMember } from "./members.js";
3
3
  interface QueryState<R> {
4
4
  rows: R[];
5
+ /**
6
+ * True only on the initial load — a request is in flight and there are no
7
+ * rows yet. Stays false during background revalidation and while typing a new
8
+ * query (the previous rows remain visible), so consumers never blank data to a
9
+ * spinner on refetch. Use `isValidating` for a subtle refetch indicator.
10
+ */
5
11
  loading: boolean;
12
+ /** True whenever any request is in flight (initial load or revalidation). */
13
+ isValidating: boolean;
6
14
  error: string | null;
7
15
  /**
8
16
  * Re-run the query from the first page. Use after a known mutation point —
@@ -37,6 +45,14 @@ export interface QueryOptions {
37
45
  * whole table on first paint. Default `true`.
38
46
  */
39
47
  enabled?: boolean;
48
+ /**
49
+ * When `false`, the query does not auto-refetch on window focus / tab return /
50
+ * network reconnect (`refetch()` still works). Default `true`, right for
51
+ * dashboards that should stay fresh. Set `false` for transient queries — a
52
+ * search bound to an ephemeral term, or on-demand detail — where a refocus
53
+ * re-run is wasted work and a visible reload.
54
+ */
55
+ revalidateOnFocus?: boolean;
40
56
  }
41
57
  /**
42
58
  * Trigger a workflow by alias from the app's manifest.
package/dist/src/hooks.js CHANGED
@@ -9,17 +9,16 @@
9
9
  * a workflow attaches it, so file upload keeps that same property.
10
10
  *
11
11
  * Every hook is a thin wrapper over the postMessage RPC bridge — the parent
12
- * does the actual API calls, results stream back through `rpc()`. Hooks manage
13
- * their own local cache via `useState`; we intentionally don't ship a global
14
- * store in v1.
12
+ * does the actual API calls, results stream back through `rpc()`. `useQuery`
13
+ * caches through SWR keyed by (alias, params, pageSize): reads dedupe and the
14
+ * cache survives unmount/remount. Mutation / member hooks keep their own local
15
+ * `useState` — they have nothing to share.
15
16
  */
16
17
  import { useCallback, useEffect, useState } from "react";
18
+ import useSWRInfinite from "swr/infinite";
17
19
  import { rpc } from "./rpc.js";
18
20
  import { getMockRows } from "./mock.js";
19
21
  import { captureAppEvent } from "./analytics.js";
20
- // A tab return can fire both `focus` and `visibilitychange` back-to-back;
21
- // coalesce them into a single revalidation.
22
- const FOCUS_REVALIDATE_THROTTLE_MS = 2000;
23
22
  export function useWorkflow(alias) {
24
23
  return useCallback(async (inputs) => {
25
24
  try {
@@ -36,120 +35,73 @@ export function useWorkflow(alias) {
36
35
  export function useQuery(alias, params, opts) {
37
36
  const pageSize = opts?.pageSize;
38
37
  const enabled = opts?.enabled ?? true;
39
- // Stringified params key structurally-equal-but-new param objects don't
40
- // re-fire the effect every render.
41
- const paramsKey = JSON.stringify(params ?? {});
42
- // Bumping this token re-fires the effect (first page) without changing
43
- // alias/params. State transitions happen inside the effect so loading /
44
- // error / rows flow consistently.
45
- const [refetchToken, setRefetchToken] = useState(0);
46
- // Initialize from the fixture when mock mode is on and the app registered
47
- // rows for this alias avoids a flash of `loading: true` on first paint.
48
- const [state, setState] = useState(() => {
49
- const mockRows = getMockRows(alias);
50
- if (mockRows)
51
- return { rows: mockRows, loading: false, error: null, hasMore: false };
52
- // A disabled query starts idle (no loading flash), not pending.
53
- return { rows: [], loading: enabled, error: null, hasMore: false };
54
- });
55
- const [loadingMore, setLoadingMore] = useState(false);
56
- useEffect(() => {
57
- // Re-check on every effect run so HMR updates to the fixture propagate
58
- // without a hard reload. Fixture short-circuits the RPC.
59
- const mockRows = getMockRows(alias);
60
- if (mockRows) {
61
- setState({ rows: mockRows, loading: false, error: null, hasMore: false });
62
- return;
38
+ const revalidateOnFocus = opts?.revalidateOnFocus ?? true;
39
+ // A registered fixture short-circuits the network in mock / dev mode.
40
+ const mockRows = getMockRows(alias);
41
+ // The cache key is (alias, params, pageSize, pageIndex), built HERE so an app
42
+ // never constructs a key it just calls `useQuery("hoSo")`. SWR canonicalizes
43
+ // the params object via its stable hash, so every `useQuery` with the same
44
+ // (alias, params, pageSize) dedupes to one request and one shared cache entry,
45
+ // and that entry survives unmount/remount navigating back to a screen shows
46
+ // cached rows instantly and revalidates in the background (no flash). A `null`
47
+ // key disables the fetch (mock / disabled / past the last page).
48
+ const getKey = (index, prev) => {
49
+ if (mockRows || !enabled)
50
+ return null;
51
+ // Non-paginated has only page 0; paginated stops once a short page returns.
52
+ if (index > 0 && (pageSize == null || prev == null || prev.rows.length < pageSize)) {
53
+ return null;
63
54
  }
64
- if (!enabled) {
65
- setState({ rows: [], loading: false, error: null, hasMore: false });
66
- return;
67
- }
68
- let cancelled = false;
69
- setState((s) => ({ ...s, loading: true, error: null }));
70
- rpc("query", {
55
+ return ["app-query", alias, params ?? {}, pageSize ?? null, index];
56
+ };
57
+ const swr = useSWRInfinite(getKey, (key) => {
58
+ const index = Number(key[4]);
59
+ return rpc("query", {
71
60
  alias,
72
61
  params: params ?? {},
73
62
  limit: pageSize,
74
- offset: 0,
75
- })
76
- .then((result) => {
77
- if (cancelled)
78
- return;
79
- const rows = result.rows ?? [];
80
- setState({
81
- rows,
82
- loading: false,
83
- error: null,
84
- hasMore: pageSize != null && rows.length === pageSize,
85
- });
86
- })
87
- .catch((err) => {
88
- if (cancelled)
89
- return;
90
- setState({ rows: [], loading: false, error: err.message, hasMore: false });
63
+ offset: pageSize != null ? index * pageSize : 0,
91
64
  });
92
- return () => {
93
- cancelled = true;
94
- };
95
- // eslint-disable-next-line react-hooks/exhaustive-deps
96
- }, [alias, paramsKey, refetchToken, pageSize, enabled]);
65
+ }, {
66
+ // loadMore appends pages — don't re-fetch earlier pages when `size` grows.
67
+ revalidateFirstPage: false,
68
+ // The opt-out covers focus AND reconnect, per the documented contract.
69
+ revalidateOnFocus,
70
+ revalidateOnReconnect: revalidateOnFocus,
71
+ // Surface a failed query immediately and keep the last good rows — no
72
+ // silent retry loop that would mask the error from the app.
73
+ shouldRetryOnError: false,
74
+ });
75
+ // SWRInfinite leaves an in-flight page slot `undefined` until it resolves
76
+ // (the typed `QueryPage[]` understates this) — operate on resolved pages only,
77
+ // so a page being appended never crashes the flatten or skews the counts.
78
+ const pages = (swr.data ?? []).filter((p) => p != null);
79
+ const rows = mockRows ?? pages.flatMap((p) => p.rows ?? []);
80
+ const lastPage = pages.length > 0 ? pages[pages.length - 1] : undefined;
81
+ const hasMore = pageSize != null && lastPage != null && (lastPage.rows?.length ?? 0) === pageSize;
82
+ // Appending a page: more pages are requested than have resolved.
83
+ const loadingMore = swr.isValidating && swr.size > pages.length;
97
84
  const refetch = useCallback(() => {
98
- setRefetchToken((n) => n + 1);
99
- }, []);
100
- // Revalidate on focus / reconnect. An app can't hold a realtime socket, so
101
- // freshness comes from re-running the query (first page) when the user
102
- // returns to it or the network comes back — plus the explicit `refetch()`
103
- // an app calls after a `useWorkflow()` mutation. Throttled so a tab return
104
- // that fires both `focus` and `visibilitychange` only refetches once.
105
- useEffect(() => {
106
- let last = 0;
107
- const revalidate = () => {
108
- if (document.visibilityState === "hidden")
109
- return;
110
- const now = performance.now();
111
- if (now - last < FOCUS_REVALIDATE_THROTTLE_MS)
112
- return;
113
- last = now;
114
- setRefetchToken((n) => n + 1);
115
- };
116
- window.addEventListener("focus", revalidate);
117
- window.addEventListener("online", revalidate);
118
- document.addEventListener("visibilitychange", revalidate);
119
- return () => {
120
- window.removeEventListener("focus", revalidate);
121
- window.removeEventListener("online", revalidate);
122
- document.removeEventListener("visibilitychange", revalidate);
123
- };
124
- }, []);
85
+ void swr.mutate();
86
+ }, [swr]);
125
87
  const loadMore = useCallback(() => {
126
- if (pageSize == null || loadingMore || state.loading || !state.hasMore)
88
+ if (pageSize == null || !hasMore || loadingMore)
127
89
  return;
128
- setLoadingMore(true);
129
- rpc("query", {
130
- alias,
131
- params: params ?? {},
132
- limit: pageSize,
133
- offset: state.rows.length,
134
- })
135
- .then((result) => {
136
- const page = result.rows ?? [];
137
- setState((cur) => ({
138
- ...cur,
139
- rows: [...cur.rows, ...page],
140
- hasMore: page.length === pageSize,
141
- }));
142
- })
143
- .catch((err) => {
144
- // Keep the rows we have — a failed page-append shouldn't blank the
145
- // list — but surface the error instead of swallowing it. `hasMore`
146
- // stays true, so the app can retry `loadMore()`.
147
- setState((cur) => ({ ...cur, error: err.message }));
148
- })
149
- .finally(() => setLoadingMore(false));
150
- // eslint-disable-next-line react-hooks/exhaustive-deps
151
- }, [alias, paramsKey, pageSize, loadingMore, state.loading, state.hasMore, state.rows.length]);
152
- return { ...state, refetch, loadMore, loadingMore };
90
+ void swr.setSize((n) => n + 1);
91
+ }, [pageSize, hasMore, loadingMore, swr]);
92
+ return {
93
+ rows,
94
+ // `loading` = initial load (validating with nothing to show yet);
95
+ // `isValidating` = any request in flight. Gating a skeleton on `loading`
96
+ // never flashes on revalidation; use `isValidating` for a subtle indicator.
97
+ loading: mockRows ? false : swr.isLoading,
98
+ isValidating: mockRows ? false : swr.isValidating,
99
+ error: swr.error ? swr.error.message : null,
100
+ refetch,
101
+ loadMore,
102
+ hasMore,
103
+ loadingMore,
104
+ };
153
105
  }
154
106
  /**
155
107
  * Upload files from an app. The bytes are stored via a presigned
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
@@ -18,7 +18,8 @@
18
18
  "prepublishOnly": "npm run build"
19
19
  },
20
20
  "dependencies": {
21
- "posthog-js": "^1.352.0"
21
+ "posthog-js": "^1.352.0",
22
+ "swr": "^2.4.1"
22
23
  },
23
24
  "peerDependencies": {
24
25
  "react": "^19.2.0",