@liiift-studio/deploy-vercel-from-sanity 1.0.8 → 1.0.10

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Liiift Studio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![Sanity v3/v4/v5](https://img.shields.io/badge/sanity-v3%20%7C%20v4%20%7C%20v5-f03e2f)](https://www.sanity.io)
7
7
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
8
8
 
9
- ![Deploy with Vercel — Production and Preview targets with live status](./docs/screenshot.png)
9
+ ![The Deploy tool inside Sanity Studio — Production and Preview targets each showing a live status badge, branch, commit SHA, deploy author, and a one-click Deploy button](https://raw.githubusercontent.com/Liiift-Studio/Deploy-Vercel-from-Sanity/main/docs/screenshot.png?v=1)
10
10
 
11
11
  ---
12
12
 
@@ -119,6 +119,28 @@ tools: (prev, { currentUser }) => {
119
119
 
120
120
  ## How it works
121
121
 
122
+ ```mermaid
123
+ flowchart LR
124
+ subgraph studio["Sanity Studio"]
125
+ tool["Deploy tool"]
126
+ end
127
+ subgraph dataset["Sanity dataset"]
128
+ targets["vercel_deploy docs<br/>(deploy targets)"]
129
+ cfg["config.vercelDeploy doc<br/>(API token)"]
130
+ end
131
+ subgraph vercel["Vercel"]
132
+ hook["Deploy hook<br/>POST /v1/integrations/deploy/…"]
133
+ api["REST API<br/>/v6/deployments · /v2/.../events"]
134
+ end
135
+
136
+ tool -- "reads targets + token" --> targets
137
+ tool -- "reads token" --> cfg
138
+ tool -- "1 - click Deploy" --> hook
139
+ hook -- "queues a build" --> api
140
+ api -- "poll every 5s while active" --> tool
141
+ tool -. "toast on Ready / Error / Canceled" .-> tool
142
+ ```
143
+
122
144
  1. Deploy targets are stored as `vercel_deploy` documents in your Sanity dataset.
123
145
  2. The plugin fetches the last 10 deployments for each target from the Vercel API, filtered to those triggered by that hook.
124
146
  3. While a deployment is active (Queued / Initializing / Building), it polls every 5 seconds.
@@ -164,7 +186,7 @@ If "No stderr or stdout was captured" appears, the build may have failed before
164
186
 
165
187
  ## Security
166
188
 
167
- **API token storage** — The Vercel API token is stored in a `config.vercelDeploy` Sanity document, readable by all authenticated studio users. Audit who has access to your Sanity project at sanity.io → Project → Members. If your studio includes untrusted editors, consider a server-side proxy that holds the token and exposes only a scoped deploy endpoint.
189
+ **API token storage** — The Vercel API token is stored in a `config.vercelDeploy` Sanity document, readable by all authenticated studio users. Note that a **Full Account** scoped token can read and write your entire Vercel account, so anyone with studio access can read a credential that grants broad Vercel access — treat the token accordingly. Audit who has access to your Sanity project at sanity.io → Project → Members. If your studio includes untrusted editors, consider a server-side proxy that holds the token and exposes only a scoped deploy endpoint.
168
190
 
169
191
  **Deploy hook URL validation** — `triggerDeploy` validates that the hook URL matches `api.vercel.com/v1/integrations/deploy/` before making the request, preventing SSRF from a tampered document.
170
192
 
@@ -180,12 +202,16 @@ If "No stderr or stdout was captured" appears, the build may have failed before
180
202
  - React 18 or 19
181
203
  - A Vercel account with at least one project and a deploy hook configured
182
204
 
205
+ Zero runtime dependencies — `react` and `sanity` are peer dependencies provided by your Studio; the plugin ships nothing else.
206
+
183
207
  ---
184
208
 
185
209
  ## Contributing
186
210
 
187
211
  Issues and pull requests welcome at [github.com/Liiift-Studio/Deploy-Vercel-from-Sanity](https://github.com/Liiift-Studio/Deploy-Vercel-from-Sanity).
188
212
 
213
+ Local development, `npm link`, and publishing steps are documented in [SETUP.md](./SETUP.md).
214
+
189
215
  ---
190
216
 
191
217
  ## License
package/dist/index.d.mts CHANGED
@@ -66,7 +66,7 @@ declare const vercelDeploySchema: {
66
66
  *
67
67
  * @example
68
68
  * // sanity.config.ts
69
- * import { vercelDeploy } from 'deploy-vercel-from-sanity'
69
+ * import { vercelDeploy } from '@liiift-studio/deploy-vercel-from-sanity'
70
70
  *
71
71
  * export default defineConfig({
72
72
  * plugins: [
package/dist/index.d.ts CHANGED
@@ -66,7 +66,7 @@ declare const vercelDeploySchema: {
66
66
  *
67
67
  * @example
68
68
  * // sanity.config.ts
69
- * import { vercelDeploy } from 'deploy-vercel-from-sanity'
69
+ * import { vercelDeploy } from '@liiift-studio/deploy-vercel-from-sanity'
70
70
  *
71
71
  * export default defineConfig({
72
72
  * plugins: [
package/dist/index.js CHANGED
@@ -283,12 +283,13 @@ function DeployHistory({ target, token, onClose }) {
283
283
  var import_jsx_runtime3 = require("react/jsx-runtime");
284
284
  var POLL_INTERVAL_MS = 5e3;
285
285
  var LABEL_WIDTH = 64;
286
+ var PENDING_TIMEOUT_MS = 6e4;
286
287
  function DeployItem({ target, token, onDelete, onEdit }) {
287
288
  const { projectId, hookId } = parseHookUrl(target.url);
288
289
  const toast = (0, import_ui3.useToast)();
289
290
  const [deployments, setDeployments] = (0, import_react2.useState)([]);
290
291
  const [loadingInitial, setLoadingInitial] = (0, import_react2.useState)(true);
291
- const [triggering, setTriggering] = (0, import_react2.useState)(false);
292
+ const [pendingSince, setPendingSince] = (0, import_react2.useState)(null);
292
293
  const [canceling, setCanceling] = (0, import_react2.useState)(false);
293
294
  const [deployError, setDeployError] = (0, import_react2.useState)(null);
294
295
  const [showHistory, setShowHistory] = (0, import_react2.useState)(false);
@@ -299,8 +300,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
299
300
  const [errorLines, setErrorLines] = (0, import_react2.useState)([]);
300
301
  const [loadingLogs, setLoadingLogs] = (0, import_react2.useState)(false);
301
302
  const [logError, setLogError] = (0, import_react2.useState)(null);
303
+ const triggeredFromUidRef = (0, import_react2.useRef)(void 0);
302
304
  const latest = deployments[0];
303
- const isActive = triggering || isActiveState(latest?.state);
305
+ const isPending = pendingSince !== null;
306
+ const isActive = isPending || isActiveState(latest?.state);
304
307
  const fetchDeployments = (0, import_react2.useCallback)(async () => {
305
308
  if (!projectId || !hookId || !token) return;
306
309
  try {
@@ -319,8 +322,14 @@ function DeployItem({ target, token, onDelete, onEdit }) {
319
322
  return () => clearInterval(id);
320
323
  }, [isActive, fetchDeployments]);
321
324
  (0, import_react2.useEffect)(() => {
322
- if (triggering && latest && latest.state !== void 0) setTriggering(false);
323
- }, [triggering, latest]);
325
+ if (!isPending) return;
326
+ if (latest?.uid && latest.uid !== triggeredFromUidRef.current) setPendingSince(null);
327
+ }, [isPending, latest?.uid]);
328
+ (0, import_react2.useEffect)(() => {
329
+ if (!isPending) return;
330
+ const id = setTimeout(() => setPendingSince(null), PENDING_TIMEOUT_MS);
331
+ return () => clearTimeout(id);
332
+ }, [isPending]);
324
333
  const prevStateRef = (0, import_react2.useRef)(void 0);
325
334
  (0, import_react2.useEffect)(() => {
326
335
  const current = latest?.state;
@@ -344,7 +353,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
344
353
  const timerRef = (0, import_react2.useRef)(null);
345
354
  (0, import_react2.useEffect)(() => {
346
355
  if (isActive) {
347
- const start = latest?.created ?? Date.now();
356
+ const start = pendingSince ?? latest?.created ?? Date.now();
348
357
  setElapsed(Math.floor((Date.now() - start) / 1e3));
349
358
  timerRef.current = setInterval(() => {
350
359
  setElapsed(Math.floor((Date.now() - start) / 1e3));
@@ -356,22 +365,23 @@ function DeployItem({ target, token, onDelete, onEdit }) {
356
365
  return () => {
357
366
  if (timerRef.current) clearInterval(timerRef.current);
358
367
  };
359
- }, [isActive, latest?.created]);
368
+ }, [isActive, pendingSince, latest?.created]);
360
369
  const deploy = (0, import_react2.useCallback)(() => {
361
370
  (0, import_react_dom.flushSync)(() => {
362
371
  setDeployError(null);
363
- setTriggering(true);
372
+ triggeredFromUidRef.current = latest?.uid;
373
+ setPendingSince(Date.now());
364
374
  });
365
375
  void (async () => {
366
376
  try {
367
377
  await triggerDeploy(target.url);
368
378
  setTimeout(fetchDeployments, 2e3);
369
379
  } catch (err) {
370
- setTriggering(false);
380
+ setPendingSince(null);
371
381
  setDeployError(err instanceof Error ? err.message : "Deploy failed");
372
382
  }
373
383
  })();
374
- }, [target.url, fetchDeployments]);
384
+ }, [target.url, fetchDeployments, latest?.uid]);
375
385
  const cancel = (0, import_react2.useCallback)(async () => {
376
386
  if (!latest?.uid) return;
377
387
  setCanceling(true);
@@ -437,7 +447,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
437
447
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", style: { marginLeft: "-0.1em", opacity: 0.5 }, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("path", { d: "M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm0 2.122a2.25 2.25 0 1 0-1.5 0v.878A2.25 2.25 0 0 0 5.75 8.5h1.5v2.128a2.251 2.251 0 1 0 1.5 0V8.5h1.5a2.25 2.25 0 0 0 2.25-2.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75h-4.5A.75.75 0 0 1 5 6.25v-.878zm3.75 7.378a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm3-8.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0z" }) })
438
448
  ] }) }),
439
449
  token && !loadingInitial && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
440
- triggering ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Badge, { tone: "caution", padding: 2, children: "Triggering\u2026" }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(StatusBadge, { state: latest?.state }),
450
+ isPending ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(StatusBadge, { state: "QUEUED" }) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(StatusBadge, { state: latest?.state }),
441
451
  isActiveState(latest?.state) && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
442
452
  import_ui3.Button,
443
453
  {
@@ -450,10 +460,13 @@ function DeployItem({ target, token, onDelete, onEdit }) {
450
460
  style: { cursor: "pointer" }
451
461
  }
452
462
  ),
453
- isActive && elapsed > 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Flex, { align: "center", gap: 1, children: [
463
+ isPending ? (
464
+ /* Optimistic — dimmed spinner only; no elapsed time until a real build exists */
465
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Spinner, { muted: true, style: { marginLeft: 4, opacity: 0.5 } })
466
+ ) : isActive ? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_ui3.Flex, { align: "center", gap: 1, children: [
454
467
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Spinner, { muted: true, style: { marginLeft: 4 } }),
455
468
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, muted: true, children: formatDuration(elapsed) })
456
- ] }) : !isActive && deployedAt ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, muted: true, children: deployedAt }) : null
469
+ ] }) : deployedAt ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_ui3.Text, { size: 1, muted: true, children: deployedAt }) : null
457
470
  ] })
458
471
  ] }),
459
472
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
@@ -696,6 +709,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
696
709
  {
697
710
  text: "Deploy",
698
711
  tone: "primary",
712
+ loading: isPending,
699
713
  disabled: isActive,
700
714
  onClick: deploy,
701
715
  style: {
@@ -936,7 +950,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
936
950
  }
937
951
 
938
952
  // src/version.ts
939
- var VERSION = "1.0.8";
953
+ var VERSION = "1.0.10";
940
954
 
941
955
  // src/components/DeployTool.tsx
942
956
  var import_jsx_runtime6 = require("react/jsx-runtime");
package/dist/index.mjs CHANGED
@@ -305,12 +305,13 @@ function DeployHistory({ target, token, onClose }) {
305
305
  import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
306
306
  var POLL_INTERVAL_MS = 5e3;
307
307
  var LABEL_WIDTH = 64;
308
+ var PENDING_TIMEOUT_MS = 6e4;
308
309
  function DeployItem({ target, token, onDelete, onEdit }) {
309
310
  const { projectId, hookId } = parseHookUrl(target.url);
310
311
  const toast = useToast();
311
312
  const [deployments, setDeployments] = useState2([]);
312
313
  const [loadingInitial, setLoadingInitial] = useState2(true);
313
- const [triggering, setTriggering] = useState2(false);
314
+ const [pendingSince, setPendingSince] = useState2(null);
314
315
  const [canceling, setCanceling] = useState2(false);
315
316
  const [deployError, setDeployError] = useState2(null);
316
317
  const [showHistory, setShowHistory] = useState2(false);
@@ -321,8 +322,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
321
322
  const [errorLines, setErrorLines] = useState2([]);
322
323
  const [loadingLogs, setLoadingLogs] = useState2(false);
323
324
  const [logError, setLogError] = useState2(null);
325
+ const triggeredFromUidRef = useRef(void 0);
324
326
  const latest = deployments[0];
325
- const isActive = triggering || isActiveState(latest?.state);
327
+ const isPending = pendingSince !== null;
328
+ const isActive = isPending || isActiveState(latest?.state);
326
329
  const fetchDeployments = useCallback2(async () => {
327
330
  if (!projectId || !hookId || !token) return;
328
331
  try {
@@ -341,8 +344,14 @@ function DeployItem({ target, token, onDelete, onEdit }) {
341
344
  return () => clearInterval(id);
342
345
  }, [isActive, fetchDeployments]);
343
346
  useEffect2(() => {
344
- if (triggering && latest && latest.state !== void 0) setTriggering(false);
345
- }, [triggering, latest]);
347
+ if (!isPending) return;
348
+ if (latest?.uid && latest.uid !== triggeredFromUidRef.current) setPendingSince(null);
349
+ }, [isPending, latest?.uid]);
350
+ useEffect2(() => {
351
+ if (!isPending) return;
352
+ const id = setTimeout(() => setPendingSince(null), PENDING_TIMEOUT_MS);
353
+ return () => clearTimeout(id);
354
+ }, [isPending]);
346
355
  const prevStateRef = useRef(void 0);
347
356
  useEffect2(() => {
348
357
  const current = latest?.state;
@@ -366,7 +375,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
366
375
  const timerRef = useRef(null);
367
376
  useEffect2(() => {
368
377
  if (isActive) {
369
- const start = latest?.created ?? Date.now();
378
+ const start = pendingSince ?? latest?.created ?? Date.now();
370
379
  setElapsed(Math.floor((Date.now() - start) / 1e3));
371
380
  timerRef.current = setInterval(() => {
372
381
  setElapsed(Math.floor((Date.now() - start) / 1e3));
@@ -378,22 +387,23 @@ function DeployItem({ target, token, onDelete, onEdit }) {
378
387
  return () => {
379
388
  if (timerRef.current) clearInterval(timerRef.current);
380
389
  };
381
- }, [isActive, latest?.created]);
390
+ }, [isActive, pendingSince, latest?.created]);
382
391
  const deploy = useCallback2(() => {
383
392
  flushSync(() => {
384
393
  setDeployError(null);
385
- setTriggering(true);
394
+ triggeredFromUidRef.current = latest?.uid;
395
+ setPendingSince(Date.now());
386
396
  });
387
397
  void (async () => {
388
398
  try {
389
399
  await triggerDeploy(target.url);
390
400
  setTimeout(fetchDeployments, 2e3);
391
401
  } catch (err) {
392
- setTriggering(false);
402
+ setPendingSince(null);
393
403
  setDeployError(err instanceof Error ? err.message : "Deploy failed");
394
404
  }
395
405
  })();
396
- }, [target.url, fetchDeployments]);
406
+ }, [target.url, fetchDeployments, latest?.uid]);
397
407
  const cancel = useCallback2(async () => {
398
408
  if (!latest?.uid) return;
399
409
  setCanceling(true);
@@ -459,7 +469,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
459
469
  /* @__PURE__ */ jsx3("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "currentColor", "aria-hidden": "true", style: { marginLeft: "-0.1em", opacity: 0.5 }, children: /* @__PURE__ */ jsx3("path", { d: "M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm0 2.122a2.25 2.25 0 1 0-1.5 0v.878A2.25 2.25 0 0 0 5.75 8.5h1.5v2.128a2.251 2.251 0 1 0 1.5 0V8.5h1.5a2.25 2.25 0 0 0 2.25-2.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75h-4.5A.75.75 0 0 1 5 6.25v-.878zm3.75 7.378a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm3-8.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0z" }) })
460
470
  ] }) }),
461
471
  token && !loadingInitial && /* @__PURE__ */ jsxs3(Fragment, { children: [
462
- triggering ? /* @__PURE__ */ jsx3(Badge3, { tone: "caution", padding: 2, children: "Triggering\u2026" }) : /* @__PURE__ */ jsx3(StatusBadge, { state: latest?.state }),
472
+ isPending ? /* @__PURE__ */ jsx3(StatusBadge, { state: "QUEUED" }) : /* @__PURE__ */ jsx3(StatusBadge, { state: latest?.state }),
463
473
  isActiveState(latest?.state) && /* @__PURE__ */ jsx3(
464
474
  Button2,
465
475
  {
@@ -472,10 +482,13 @@ function DeployItem({ target, token, onDelete, onEdit }) {
472
482
  style: { cursor: "pointer" }
473
483
  }
474
484
  ),
475
- isActive && elapsed > 0 ? /* @__PURE__ */ jsxs3(Flex3, { align: "center", gap: 1, children: [
485
+ isPending ? (
486
+ /* Optimistic — dimmed spinner only; no elapsed time until a real build exists */
487
+ /* @__PURE__ */ jsx3(Spinner3, { muted: true, style: { marginLeft: 4, opacity: 0.5 } })
488
+ ) : isActive ? /* @__PURE__ */ jsxs3(Flex3, { align: "center", gap: 1, children: [
476
489
  /* @__PURE__ */ jsx3(Spinner3, { muted: true, style: { marginLeft: 4 } }),
477
490
  /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, children: formatDuration(elapsed) })
478
- ] }) : !isActive && deployedAt ? /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, children: deployedAt }) : null
491
+ ] }) : deployedAt ? /* @__PURE__ */ jsx3(Text2, { size: 1, muted: true, children: deployedAt }) : null
479
492
  ] })
480
493
  ] }),
481
494
  /* @__PURE__ */ jsx3(
@@ -718,6 +731,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
718
731
  {
719
732
  text: "Deploy",
720
733
  tone: "primary",
734
+ loading: isPending,
721
735
  disabled: isActive,
722
736
  onClick: deploy,
723
737
  style: {
@@ -969,7 +983,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
969
983
  }
970
984
 
971
985
  // src/version.ts
972
- var VERSION = "1.0.8";
986
+ var VERSION = "1.0.10";
973
987
 
974
988
  // src/components/DeployTool.tsx
975
989
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/deploy-vercel-from-sanity",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "Sanity Studio plugin — trigger and monitor Vercel deployments with full status, history, and build logs. Supports v3, v4, and v5.",
5
5
  "license": "MIT",
6
6
  "author": "Liiift Studio",
@@ -17,6 +17,8 @@ import type { DeployTarget, VercelDeployment } from '../types'
17
17
 
18
18
  const POLL_INTERVAL_MS = 5_000
19
19
  const LABEL_WIDTH = 64
20
+ /** Max time (ms) to hold the optimistic pending state before giving up and deferring to real API status */
21
+ const PENDING_TIMEOUT_MS = 60_000
20
22
 
21
23
  interface DeployItemProps {
22
24
  target: DeployTarget
@@ -31,7 +33,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
31
33
 
32
34
  const [deployments, setDeployments] = useState<VercelDeployment[]>([])
33
35
  const [loadingInitial, setLoadingInitial] = useState(true)
34
- const [triggering, setTriggering] = useState(false)
36
+ const [pendingSince, setPendingSince] = useState<number | null>(null)
35
37
  const [canceling, setCanceling] = useState(false)
36
38
  const [deployError, setDeployError] = useState<string | null>(null)
37
39
  const [showHistory, setShowHistory] = useState(false)
@@ -43,8 +45,13 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
43
45
  const [loadingLogs, setLoadingLogs] = useState(false)
44
46
  const [logError, setLogError] = useState<string | null>(null)
45
47
 
46
- const latest = deployments[0]
47
- const isActive = triggering || isActiveState(latest?.state)
48
+ /** uid of the deployment that was latest when Deploy was clicked — lets us tell the optimistic state apart from a genuinely new deployment */
49
+ const triggeredFromUidRef = useRef<string | undefined>(undefined)
50
+
51
+ const latest = deployments[0]
52
+ /** True between the click and the API returning a new deployment — drives the optimistic "Queued" state */
53
+ const isPending = pendingSince !== null
54
+ const isActive = isPending || isActiveState(latest?.state)
48
55
 
49
56
  // ── Fetch deployments ──────────────────────────────────────────────────────
50
57
  const fetchDeployments = useCallback(async () => {
@@ -67,9 +74,20 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
67
74
  return () => clearInterval(id)
68
75
  }, [isActive, fetchDeployments])
69
76
 
77
+ // Hand off from the optimistic state only once the API returns a deployment
78
+ // that is not the one which was already latest when Deploy was clicked.
79
+ useEffect(() => {
80
+ if (!isPending) return
81
+ if (latest?.uid && latest.uid !== triggeredFromUidRef.current) setPendingSince(null)
82
+ }, [isPending, latest?.uid])
83
+
84
+ // Safety net — never strand the card in the optimistic state if the new
85
+ // deployment never appears (hook accepted but nothing was queued).
70
86
  useEffect(() => {
71
- if (triggering && latest && latest.state !== undefined) setTriggering(false)
72
- }, [triggering, latest])
87
+ if (!isPending) return
88
+ const id = setTimeout(() => setPendingSince(null), PENDING_TIMEOUT_MS)
89
+ return () => clearTimeout(id)
90
+ }, [isPending])
73
91
 
74
92
  //── Deploy-complete toast ─────────────────────────────────────────────────
75
93
  const prevStateRef = useRef<string | undefined>(undefined)
@@ -98,7 +116,8 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
98
116
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
99
117
  useEffect(() => {
100
118
  if (isActive) {
101
- const start = latest?.created ?? Date.now()
119
+ // While optimistic, count from the click; once real, count from the deployment's own timestamp
120
+ const start = pendingSince ?? latest?.created ?? Date.now()
102
121
  setElapsed(Math.floor((Date.now() - start) / 1000))
103
122
  timerRef.current = setInterval(() => {
104
123
  setElapsed(Math.floor((Date.now() - start) / 1000))
@@ -108,24 +127,27 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
108
127
  if (timerRef.current) clearInterval(timerRef.current)
109
128
  }
110
129
  return () => { if (timerRef.current) clearInterval(timerRef.current) }
111
- }, [isActive, latest?.created])
130
+ }, [isActive, pendingSince, latest?.created])
112
131
 
113
132
  // ── Actions ───────────────────────────────────────────────────────────────
114
133
  const deploy = useCallback(() => {
134
+ // Paint the optimistic "Queued" state in the same frame as the click,
135
+ // before the hook request is even sent
115
136
  flushSync(() => {
116
137
  setDeployError(null)
117
- setTriggering(true)
138
+ triggeredFromUidRef.current = latest?.uid
139
+ setPendingSince(Date.now())
118
140
  })
119
141
  void (async () => {
120
142
  try {
121
143
  await triggerDeploy(target.url)
122
144
  setTimeout(fetchDeployments, 2000)
123
145
  } catch (err) {
124
- setTriggering(false)
146
+ setPendingSince(null)
125
147
  setDeployError(err instanceof Error ? err.message : 'Deploy failed')
126
148
  }
127
149
  })()
128
- }, [target.url, fetchDeployments])
150
+ }, [target.url, fetchDeployments, latest?.uid])
129
151
 
130
152
  const cancel = useCallback(async () => {
131
153
  if (!latest?.uid) return
@@ -218,8 +240,8 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
218
240
  )}
219
241
  {token && !loadingInitial && (
220
242
  <>
221
- {triggering ? (
222
- <Badge tone="caution" padding={2}>Triggering…</Badge>
243
+ {isPending ? (
244
+ <StatusBadge state="QUEUED" />
223
245
  ) : (
224
246
  <StatusBadge state={latest?.state} />
225
247
  )}
@@ -234,12 +256,15 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
234
256
  style={{ cursor: 'pointer' }}
235
257
  />
236
258
  )}
237
- {isActive && elapsed > 0 ? (
259
+ {isPending ? (
260
+ /* Optimistic — dimmed spinner only; no elapsed time until a real build exists */
261
+ <Spinner muted style={{ marginLeft: 4, opacity: 0.5 }} />
262
+ ) : isActive ? (
238
263
  <Flex align="center" gap={1}>
239
264
  <Spinner muted style={{ marginLeft: 4 }} />
240
265
  <Text size={1} muted>{formatDuration(elapsed)}</Text>
241
266
  </Flex>
242
- ) : (!isActive && deployedAt) ? (
267
+ ) : deployedAt ? (
243
268
  <Text size={1} muted>{deployedAt}</Text>
244
269
  ) : null}
245
270
  </>
@@ -562,6 +587,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
562
587
  <Button
563
588
  text="Deploy"
564
589
  tone="primary"
590
+ loading={isPending}
565
591
  disabled={isActive}
566
592
  onClick={deploy}
567
593
  style={{
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@ export type { VercelDeployPluginConfig, DeployTarget, VercelDeployment, VercelDe
13
13
  *
14
14
  * @example
15
15
  * // sanity.config.ts
16
- * import { vercelDeploy } from 'deploy-vercel-from-sanity'
16
+ * import { vercelDeploy } from '@liiift-studio/deploy-vercel-from-sanity'
17
17
  *
18
18
  * export default defineConfig({
19
19
  * plugins: [
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Package version — keep in sync with package.json
2
- export const VERSION = '1.0.8'
2
+ export const VERSION = '1.0.10'