@gmickel/gno 1.39.2 → 1.40.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.
@@ -460,6 +460,10 @@ mark {
460
460
 
461
461
  .gno-pdf-page-column {
462
462
  scrollbar-gutter: stable;
463
+ /* The page hook is the sole scroll compensator for geometry corrections
464
+ (use-pdf-pages.ts anchored correction); browser scroll anchoring on top
465
+ of it would move the page in view by the delta a second time. */
466
+ overflow-anchor: none;
463
467
  }
464
468
 
465
469
  .gno-pdf-page {
@@ -4,9 +4,11 @@ import {
4
4
  classifyPdfError as defaultClassifyPdfError,
5
5
  getDocument as defaultGetDocument,
6
6
  getPdfMetrics as defaultGetPdfMetrics,
7
+ transportHintForContentLength,
7
8
  type GnoDocumentLoadingTask,
8
9
  type PdfFallbackReason,
9
10
  type PDFDocumentProxy,
11
+ type PdfTransportHint,
10
12
  } from "../lib/pdf";
11
13
 
12
14
  export type PdfDocumentStatus = "loading" | "ready" | "error";
@@ -30,12 +32,18 @@ export type UsePdfDocumentDeps = {
30
32
  getDocument?: typeof defaultGetDocument;
31
33
  classifyPdfError?: typeof defaultClassifyPdfError;
32
34
  getPdfMetrics?: typeof defaultGetPdfMetrics;
35
+ /** HEAD-probe transport; the JSON API helper cannot carry a HEAD. */
36
+ fetch?: FetchLike;
33
37
  };
34
38
 
39
+ /** Minimal fetch shape the HEAD probe needs (same-origin string URL only). */
40
+ export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
41
+
35
42
  type LoadOwnership = {
36
- /** Minted opaque id for this load attempt. */
37
- docId: string;
38
- task: GnoDocumentLoadingTask;
43
+ /** Minted opaque id for this load attempt; null until the probe settles. */
44
+ docId: string | null;
45
+ /** Null while the HEAD probe is in flight; never created once torn down. */
46
+ task: GnoDocumentLoadingTask | null;
39
47
  /** Set only after promise resolves into viewer ownership. */
40
48
  viewerDoc: PDFDocumentProxy | null;
41
49
  /** True once teardown for this load has run (idempotent). */
@@ -44,6 +52,33 @@ type LoadOwnership = {
44
52
  destroyMetricEmitted: boolean;
45
53
  };
46
54
 
55
+ /** Module-level so the effect dependency stays referentially stable. */
56
+ const defaultFetch: FetchLike = (url, init) => fetch(url, init);
57
+
58
+ /**
59
+ * Probe the asset size with one same-origin HEAD (fn-136 R1). Network failure,
60
+ * a non-2xx status, or a missing/invalid Content-Length all fall back to the
61
+ * ranged tier; the probe never rejects and never fails the document.
62
+ */
63
+ async function probeTransportHint(
64
+ url: string,
65
+ fetchImpl: FetchLike
66
+ ): Promise<PdfTransportHint> {
67
+ try {
68
+ const response = await fetchImpl(url, { method: "HEAD" });
69
+ if (!response.ok) {
70
+ return "ranged";
71
+ }
72
+ const header = response.headers.get("content-length");
73
+ if (header === null || !/^\d+$/u.test(header.trim())) {
74
+ return "ranged";
75
+ }
76
+ return transportHintForContentLength(Number(header));
77
+ } catch {
78
+ return "ranged";
79
+ }
80
+ }
81
+
47
82
  /**
48
83
  * Load a PDF document from a same-origin asset URL.
49
84
  *
@@ -59,6 +94,7 @@ export function usePdfDocument(
59
94
  const getDocument = deps.getDocument ?? defaultGetDocument;
60
95
  const classifyPdfError = deps.classifyPdfError ?? defaultClassifyPdfError;
61
96
  const getPdfMetrics = deps.getPdfMetrics ?? defaultGetPdfMetrics;
97
+ const fetchImpl = deps.fetch ?? defaultFetch;
62
98
 
63
99
  const [status, setStatus] = useState<PdfDocumentStatus>("loading");
64
100
  const [doc, setDoc] = useState<PDFDocumentProxy | null>(null);
@@ -99,13 +135,11 @@ export function usePdfDocument(
99
135
  setError(null);
100
136
  setErrorMessage(null);
101
137
 
102
- const loadingTask = getDocument({ url });
103
- const instanceDocId = loadingTask.gnoDocId;
104
- setDocId(instanceDocId);
138
+ setDocId(null);
105
139
 
106
140
  const ownership: LoadOwnership = {
107
- docId: instanceDocId,
108
- task: loadingTask,
141
+ docId: null,
142
+ task: null,
109
143
  viewerDoc: null,
110
144
  tornDown: false,
111
145
  destroyMetricEmitted: false,
@@ -119,12 +153,15 @@ export function usePdfDocument(
119
153
  if (ownership.tornDown) {
120
154
  return true;
121
155
  }
122
- return Boolean((loadingTask as { destroyed?: boolean }).destroyed);
156
+ return Boolean(
157
+ (ownership.task as { destroyed?: boolean } | null)?.destroyed
158
+ );
123
159
  };
124
160
 
125
161
  /**
126
162
  * Idempotent teardown for this load.
127
- * - Always destroy the loading task exactly once.
163
+ * - Destroy the loading task exactly once when one exists; a load torn
164
+ * down during the HEAD probe never creates a task.
128
165
  * - Emit documentDestroy only for a viewer-owned success.
129
166
  * - A stale late resolution needs no separate proxy cleanup: the loading
130
167
  * task already owns and destroys its transport.
@@ -138,45 +175,65 @@ export function usePdfDocument(
138
175
  const viewerDoc = ownership.viewerDoc;
139
176
  ownership.viewerDoc = null;
140
177
 
141
- if (viewerDoc) {
178
+ if (viewerDoc && ownership.docId !== null) {
142
179
  if (!ownership.destroyMetricEmitted) {
143
180
  ownership.destroyMetricEmitted = true;
144
181
  metrics.recordDocumentDestroy({ docId: ownership.docId });
145
182
  }
146
183
  }
147
184
 
148
- try {
149
- void ownership.task.destroy().catch(() => undefined);
150
- } catch {
151
- // ignore
185
+ const task = ownership.task;
186
+ if (task) {
187
+ try {
188
+ void task.destroy().catch(() => undefined);
189
+ } catch {
190
+ // ignore
191
+ }
152
192
  }
153
193
  };
154
194
 
155
- loadingTask.promise
156
- .then(async (pdf) => {
195
+ const failLoad = (err: unknown): void => {
196
+ if (isStale()) {
197
+ return;
198
+ }
199
+ const reason = classifyPdfError(err);
200
+ setStatus("error");
201
+ setError(reason);
202
+ setErrorMessage(err instanceof Error ? err.message : String(err));
203
+ setDoc(null);
204
+ setNumPages(0);
205
+ setFirstPageReady(false);
206
+ teardown();
207
+ };
208
+
209
+ // One HEAD per document load picks the transport tier; the load itself
210
+ // starts only if this generation is still live once the probe settles.
211
+ // A synchronous getDocument failure routes through the same error path.
212
+ void probeTransportHint(url, fetchImpl)
213
+ .then((transport) => {
157
214
  if (isStale()) {
158
- teardown();
159
215
  return;
160
216
  }
161
- ownership.viewerDoc = pdf;
162
- setDoc(pdf);
163
- setNumPages(pdf.numPages);
164
- setStatus("ready");
165
- setFirstPageReady(pdf.numPages > 0);
217
+ const loadingTask = getDocument({ url, transport });
218
+ ownership.task = loadingTask;
219
+ ownership.docId = loadingTask.gnoDocId;
220
+ setDocId(loadingTask.gnoDocId);
221
+
222
+ loadingTask.promise
223
+ .then(async (pdf) => {
224
+ if (isStale()) {
225
+ teardown();
226
+ return;
227
+ }
228
+ ownership.viewerDoc = pdf;
229
+ setDoc(pdf);
230
+ setNumPages(pdf.numPages);
231
+ setStatus("ready");
232
+ setFirstPageReady(pdf.numPages > 0);
233
+ })
234
+ .catch(failLoad);
166
235
  })
167
- .catch((err: unknown) => {
168
- if (isStale()) {
169
- return;
170
- }
171
- const reason = classifyPdfError(err);
172
- setStatus("error");
173
- setError(reason);
174
- setErrorMessage(err instanceof Error ? err.message : String(err));
175
- setDoc(null);
176
- setNumPages(0);
177
- setFirstPageReady(false);
178
- teardown();
179
- });
236
+ .catch(failLoad);
180
237
 
181
238
  return () => {
182
239
  if (ownershipRef.current === ownership) {
@@ -184,7 +241,14 @@ export function usePdfDocument(
184
241
  }
185
242
  teardown();
186
243
  };
187
- }, [url, retryToken, getDocument, classifyPdfError, getPdfMetrics]);
244
+ }, [
245
+ url,
246
+ retryToken,
247
+ getDocument,
248
+ classifyPdfError,
249
+ getPdfMetrics,
250
+ fetchImpl,
251
+ ]);
188
252
 
189
253
  return {
190
254
  status,