@gmickel/gno 1.29.5 → 1.30.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.
Files changed (30) hide show
  1. package/README.md +15 -5
  2. package/browser-extension/artifacts/{gno-browser-clipper-v1.29.5.zip → gno-browser-clipper-v1.30.1.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.30.1.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +4 -1
  6. package/src/core/network-boundary-inventory.ts +51 -0
  7. package/src/ingestion/index.ts +1 -1
  8. package/src/ingestion/walker.ts +45 -23
  9. package/src/serve/AGENTS.md +5 -1
  10. package/src/serve/CLAUDE.md +5 -1
  11. package/src/serve/fn112-routes.ts +232 -0
  12. package/src/serve/pdfjs-assets.ts +391 -0
  13. package/src/serve/public/components/pdf/PdfPageView.tsx +427 -0
  14. package/src/serve/public/components/pdf/PdfToolbar.tsx +384 -0
  15. package/src/serve/public/components/pdf/PdfViewer.tsx +539 -0
  16. package/src/serve/public/components/pdf/pdf-viewer-deps.tsx +94 -0
  17. package/src/serve/public/globals.built.css +1 -1
  18. package/src/serve/public/globals.css +113 -0
  19. package/src/serve/public/hooks/use-pdf-document.ts +227 -0
  20. package/src/serve/public/hooks/use-pdf-pages.ts +1197 -0
  21. package/src/serve/public/lib/doc-asset-url.ts +57 -0
  22. package/src/serve/public/lib/math-sum-precise.ts +34 -0
  23. package/src/serve/public/lib/pdf.ts +772 -0
  24. package/src/serve/public/pages/DocView.tsx +295 -39
  25. package/src/serve/public/pages/doc-pdf-viewer.tsx +7 -0
  26. package/src/serve/routes/api.ts +154 -14
  27. package/src/serve/server.ts +190 -37
  28. package/src/serve/spa-bundle-source.ts +99 -0
  29. package/src/serve/watch-service.ts +219 -23
  30. package/browser-extension/artifacts/gno-browser-clipper-v1.29.5.zip.sha256 +0 -1
@@ -0,0 +1,1197 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ import {
4
+ classifyPdfError,
5
+ computeEffectiveScale as defaultComputeEffectiveScale,
6
+ getPdfMetrics as defaultGetPdfMetrics,
7
+ isRenderingCancelled as defaultIsRenderingCancelled,
8
+ type PDFDocumentProxy,
9
+ type PDFPageProxy,
10
+ type PdfFallbackReason,
11
+ type RenderTask,
12
+ } from "../lib/pdf";
13
+
14
+ export const LIVE_CANVAS_CEILING = 10;
15
+ export const OVERSCAN_PAGES = 2;
16
+ /**
17
+ * Visible-set quiescence required before a page that entered the live window
18
+ * during scrolling is admitted to render. A **production behavior constant** —
19
+ * never harness-derived, injected, or tuned from the smoke.
20
+ *
21
+ * Without this, an N-page traversal issues N `renderStart`s because every page
22
+ * transiting the window starts a render (measured: 200 starts on the 200-page
23
+ * P-3 procedure). Pages that enter and leave before quiescence are never
24
+ * admitted and emit no metric events at all.
25
+ */
26
+ export const SCROLL_QUIESCENCE_MS = 120;
27
+
28
+ export type FitMode = "width" | "page" | "custom";
29
+
30
+ export type PageSlotState = {
31
+ pageNumber: number;
32
+ /** CSS viewport width at current scale */
33
+ width: number;
34
+ /** CSS viewport height at current scale */
35
+ height: number;
36
+ rendered: boolean;
37
+ /** True when IntersectionObserver reports the page is intersecting. */
38
+ visible: boolean;
39
+ /**
40
+ * True when the page is in the live window (visible ± overscan) and should
41
+ * mount canvas / TextLayer. Production PdfPageView `active` prop.
42
+ */
43
+ active: boolean;
44
+ };
45
+
46
+ export type UsePdfPagesOptions = {
47
+ doc: PDFDocumentProxy | null;
48
+ docId: string | null;
49
+ numPages: number;
50
+ /** Logical zoom (1 = 100%) when fitMode is custom; otherwise a hint. */
51
+ zoom: number;
52
+ fitMode: FitMode;
53
+ containerWidth: number;
54
+ containerHeight: number;
55
+ /** Bumped by viewer on every zoom/fit/scale commit. */
56
+ genId: number;
57
+ devicePixelRatio?: number;
58
+ /**
59
+ * Optional test doubles only (not a product API). Production omits these.
60
+ */
61
+ getPdfMetrics?: typeof defaultGetPdfMetrics;
62
+ computeEffectiveScale?: typeof defaultComputeEffectiveScale;
63
+ isRenderingCancelled?: typeof defaultIsRenderingCancelled;
64
+ /** Injectable IntersectionObserver for controlled tests. */
65
+ IntersectionObserverImpl?: typeof IntersectionObserver;
66
+ };
67
+
68
+ export type UsePdfPagesResult = {
69
+ slots: PageSlotState[];
70
+ /** Geometry/page acquisition failure classified for the viewer state model. */
71
+ error: PdfFallbackReason | null;
72
+ liveCanvasCount: number;
73
+ observePage: (pageNumber: number, el: HTMLElement | null) => void;
74
+ /**
75
+ * Register a canvas and render when the page is in the live window.
76
+ * Called by PdfPageView when `active` — not a manual test escape hatch for
77
+ * arbitrary off-window pages (those are no-ops).
78
+ */
79
+ ensureRendered: (
80
+ pageNumber: number,
81
+ canvas: HTMLCanvasElement | null
82
+ ) => Promise<void>;
83
+ scale: number;
84
+ /**
85
+ * Awaited disposal barrier: cancel → settle → page.cleanup → canvas zero for
86
+ * every live page. Effect cleanup triggers this but does not await; callers
87
+ * and tests that need completion before document teardown must await this.
88
+ */
89
+ disposeAll: () => Promise<void>;
90
+ };
91
+
92
+ type PageCache = {
93
+ page: PDFPageProxy;
94
+ task: RenderTask | null;
95
+ taskId: string | null;
96
+ startGenId: number | null;
97
+ /**
98
+ * Logical scale this entry was rendered at. A generation bump and the
99
+ * recomputed scale do NOT land in the same React pass (scale is derived from
100
+ * the ResizeObserver-fed container width), so generation identity alone
101
+ * cannot decide "already rendered" — see the same-generation scale change
102
+ * guarded below.
103
+ */
104
+ startScale: number | null;
105
+ canvas: HTMLCanvasElement | null;
106
+ settled: boolean;
107
+ /** Single-owner cancel promise — concurrent callers share one cancel/settle. */
108
+ cancelClaim: Promise<void> | null;
109
+ };
110
+
111
+ type BasePageGeometry = { width: number; height: number };
112
+
113
+ function setsEqual(a: ReadonlySet<number>, b: ReadonlySet<number>): boolean {
114
+ if (a.size !== b.size) {
115
+ return false;
116
+ }
117
+ for (const v of a) {
118
+ if (!b.has(v)) {
119
+ return false;
120
+ }
121
+ }
122
+ return true;
123
+ }
124
+
125
+ function computeActiveSet(
126
+ visible: ReadonlySet<number>,
127
+ numPages: number
128
+ ): Set<number> {
129
+ const active = new Set<number>();
130
+ for (const p of visible) {
131
+ for (
132
+ let i = Math.max(1, p - OVERSCAN_PAGES);
133
+ i <= Math.min(numPages, p + OVERSCAN_PAGES);
134
+ i++
135
+ ) {
136
+ active.add(i);
137
+ }
138
+ }
139
+ return active;
140
+ }
141
+
142
+ /** Mark a canvas as carrying a live pdfjs backing store (not browser defaults). */
143
+ function markLiveBacking(canvas: HTMLCanvasElement): void {
144
+ canvas.dataset.gnoPdfBacking = "1";
145
+ }
146
+
147
+ /** Zero dimensions and clear the live-backing marker (eviction / rollback). */
148
+ function zeroCanvasBacking(canvas: HTMLCanvasElement): void {
149
+ canvas.width = 0;
150
+ canvas.height = 0;
151
+ delete canvas.dataset.gnoPdfBacking;
152
+ }
153
+
154
+ /**
155
+ * Virtualized page render scheduling driven by IntersectionObserver.
156
+ * Cancel → await settle → cleanup → zero-dims eviction, with metrics correlation.
157
+ */
158
+ export function usePdfPages(options: UsePdfPagesOptions): UsePdfPagesResult {
159
+ const {
160
+ doc,
161
+ docId,
162
+ numPages,
163
+ zoom,
164
+ fitMode,
165
+ containerWidth,
166
+ containerHeight,
167
+ genId,
168
+ devicePixelRatio = typeof window !== "undefined"
169
+ ? window.devicePixelRatio || 1
170
+ : 1,
171
+ getPdfMetrics = defaultGetPdfMetrics,
172
+ computeEffectiveScale = defaultComputeEffectiveScale,
173
+ isRenderingCancelled = defaultIsRenderingCancelled,
174
+ IntersectionObserverImpl = typeof IntersectionObserver !== "undefined"
175
+ ? IntersectionObserver
176
+ : undefined,
177
+ } = options;
178
+
179
+ const [slots, setSlots] = useState<PageSlotState[]>([]);
180
+ const [scale, setScale] = useState(1);
181
+ const [visiblePages, setVisiblePages] = useState<Set<number>>(
182
+ () => new Set()
183
+ );
184
+ const [liveCanvasCount, setLiveCanvasCount] = useState(0);
185
+ const [pageError, setPageError] = useState<PdfFallbackReason | null>(null);
186
+
187
+ const cacheRef = useRef<Map<number, PageCache>>(new Map());
188
+ const elementsRef = useRef<Map<number, HTMLElement>>(new Map());
189
+ const canvasRef = useRef<Map<number, HTMLCanvasElement>>(new Map());
190
+ const reservationsRef = useRef<Map<number, symbol>>(new Map());
191
+ const latestRequestRef = useRef<Map<number, symbol>>(new Map());
192
+ const geometryCacheRef = useRef<
193
+ WeakMap<PDFDocumentProxy, Promise<BasePageGeometry[]>>
194
+ >(new WeakMap());
195
+ const observerRef = useRef<IntersectionObserver | null>(null);
196
+ const settledTaskIdsRef = useRef<Set<string>>(new Set());
197
+ const metrics = getPdfMetrics();
198
+ const genIdRef = useRef(genId);
199
+ genIdRef.current = genId;
200
+
201
+ /**
202
+ * Deferred-admission state.
203
+ *
204
+ * `epochSeqRef` is a monotonic counter bumped synchronously on every `docId`
205
+ * or `genId` change. Opening an epoch opens its *exempt batch*: while open,
206
+ * every page passing the ordinary active-set guard is admitted immediately —
207
+ * the whole initial window, and every active page after a zoom/fit commit.
208
+ * The batch closes at the first visible-set mutation occurring *after* it has
209
+ * admitted >= 1 page, and never reopens; only a new epoch opens the next.
210
+ */
211
+ const epochSeqRef = useRef(0);
212
+ const epochKeyRef = useRef<string | null>(null);
213
+ const epochBatchOpenRef = useRef(true);
214
+ const epochAdmittedRef = useRef(0);
215
+ const pendingRef = useRef<
216
+ Map<
217
+ number,
218
+ {
219
+ docId: string | null;
220
+ genId: number;
221
+ epochSeq: number;
222
+ pageNumber: number;
223
+ canvas: HTMLCanvasElement;
224
+ scale: number;
225
+ }
226
+ >
227
+ >(new Map());
228
+ const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
229
+ const flushPendingRef = useRef<
230
+ ((armedEpochSeq: number) => Promise<void>) | null
231
+ >(null);
232
+
233
+ const clearPending = useCallback((): void => {
234
+ pendingRef.current.clear();
235
+ if (pendingTimerRef.current !== null) {
236
+ clearTimeout(pendingTimerRef.current);
237
+ pendingTimerRef.current = null;
238
+ }
239
+ }, []);
240
+
241
+ // Synchronous epoch bump (same pattern as genIdRef above): a doc or gen change
242
+ // must open its exempt batch before any render path observes the new state.
243
+ {
244
+ const epochKey = `${docId ?? ""}:${genId}`;
245
+ if (epochKeyRef.current !== epochKey) {
246
+ epochKeyRef.current = epochKey;
247
+ epochSeqRef.current += 1;
248
+ epochBatchOpenRef.current = true;
249
+ epochAdmittedRef.current = 0;
250
+ pendingRef.current.clear();
251
+ reservationsRef.current.clear();
252
+ latestRequestRef.current.clear();
253
+ if (pendingTimerRef.current !== null) {
254
+ clearTimeout(pendingTimerRef.current);
255
+ pendingTimerRef.current = null;
256
+ }
257
+ }
258
+ }
259
+ const scaleRef = useRef(scale);
260
+ scaleRef.current = scale;
261
+ const visibleRef = useRef(visiblePages);
262
+ visibleRef.current = visiblePages;
263
+ const disposedRef = useRef(false);
264
+ const disposePromiseRef = useRef<Promise<void> | null>(null);
265
+
266
+ const markSettled = useCallback((taskId: string | null | undefined) => {
267
+ if (taskId) {
268
+ settledTaskIdsRef.current.add(taskId);
269
+ }
270
+ }, []);
271
+
272
+ const hasSettled = useCallback((taskId: string | null | undefined) => {
273
+ return Boolean(taskId && settledTaskIdsRef.current.has(taskId));
274
+ }, []);
275
+
276
+ const syncSlotWindow = useCallback(
277
+ (visible: Set<number>) => {
278
+ const active = computeActiveSet(visible, numPages);
279
+ setSlots((prev) =>
280
+ prev.map((s) => ({
281
+ ...s,
282
+ visible: visible.has(s.pageNumber),
283
+ active: active.has(s.pageNumber),
284
+ }))
285
+ );
286
+ },
287
+ [numPages]
288
+ );
289
+
290
+ // Resolve rotation-aware geometry with bounded concurrency before publishing
291
+ // slots. Correct placeholders are part of the scroll model: copying page 1
292
+ // makes mixed-size documents jump and makes fit modes overflow later pages.
293
+ useEffect(() => {
294
+ let cancelled = false;
295
+ if (!doc || numPages === 0) {
296
+ setSlots([]);
297
+ setScale(1);
298
+ setPageError(null);
299
+ return;
300
+ }
301
+
302
+ void (async () => {
303
+ let geometryPromise = geometryCacheRef.current.get(doc);
304
+ if (!geometryPromise) {
305
+ geometryPromise = (async (): Promise<BasePageGeometry[]> => {
306
+ const geometry = Array.from<BasePageGeometry | undefined>({
307
+ length: numPages,
308
+ });
309
+ let nextPageNumber = 1;
310
+ let failure: unknown = null;
311
+ const worker = async (): Promise<void> => {
312
+ while (failure === null) {
313
+ const pageNumber = nextPageNumber;
314
+ nextPageNumber += 1;
315
+ if (pageNumber > numPages) {
316
+ return;
317
+ }
318
+ try {
319
+ const page = await doc.getPage(pageNumber);
320
+ if (failure !== null) {
321
+ return;
322
+ }
323
+ const viewport = page.getViewport({ scale: 1 });
324
+ geometry[pageNumber - 1] = {
325
+ width: viewport.width,
326
+ height: viewport.height,
327
+ };
328
+ } catch (error) {
329
+ failure = error;
330
+ }
331
+ }
332
+ };
333
+ await Promise.all(
334
+ Array.from({ length: Math.min(4, numPages) }, async () => worker())
335
+ );
336
+ const resolved = geometry.filter(
337
+ (entry): entry is BasePageGeometry => entry !== undefined
338
+ );
339
+ if (failure !== null) {
340
+ throw failure;
341
+ }
342
+ if (resolved.length !== numPages) {
343
+ throw new Error("PDF page geometry is incomplete");
344
+ }
345
+ return resolved;
346
+ })();
347
+ geometryCacheRef.current.set(doc, geometryPromise);
348
+ }
349
+
350
+ let resolvedGeometry: BasePageGeometry[];
351
+ try {
352
+ resolvedGeometry = await geometryPromise;
353
+ } catch (error) {
354
+ if (cancelled) {
355
+ return;
356
+ }
357
+ setSlots([]);
358
+ setPageError(classifyPdfError(error));
359
+ return;
360
+ }
361
+ if (cancelled) {
362
+ return;
363
+ }
364
+
365
+ const maxWidth = Math.max(
366
+ ...resolvedGeometry.map((entry) => entry.width)
367
+ );
368
+ const maxHeight = Math.max(
369
+ ...resolvedGeometry.map((entry) => entry.height)
370
+ );
371
+ let nextScale = zoom;
372
+ if (fitMode === "width" && containerWidth > 0) {
373
+ nextScale = containerWidth / maxWidth;
374
+ } else if (
375
+ fitMode === "page" &&
376
+ containerWidth > 0 &&
377
+ containerHeight > 0
378
+ ) {
379
+ nextScale = Math.min(
380
+ containerWidth / maxWidth,
381
+ containerHeight / maxHeight
382
+ );
383
+ }
384
+ nextScale = Math.max(0.25, Math.min(4, nextScale));
385
+ setScale(nextScale);
386
+ setPageError(null);
387
+
388
+ const visible = visibleRef.current;
389
+ const active = computeActiveSet(visible, numPages);
390
+ setSlots(
391
+ resolvedGeometry.map((entry, index) => ({
392
+ pageNumber: index + 1,
393
+ width: entry.width * nextScale,
394
+ height: entry.height * nextScale,
395
+ rendered: false,
396
+ visible: visible.has(index + 1),
397
+ active: active.has(index + 1),
398
+ }))
399
+ );
400
+ })();
401
+
402
+ return () => {
403
+ cancelled = true;
404
+ };
405
+ }, [doc, numPages, zoom, fitMode, containerWidth, containerHeight]);
406
+
407
+ // IntersectionObserver drives visibility → slot.active/visible → PdfPageView.
408
+ useEffect(() => {
409
+ if (!IntersectionObserverImpl) {
410
+ return;
411
+ }
412
+ observerRef.current?.disconnect();
413
+ const observer = new IntersectionObserverImpl(
414
+ (entries) => {
415
+ setVisiblePages((prev) => {
416
+ const next = new Set(prev);
417
+ for (const entry of entries) {
418
+ const pageNumber = Number(
419
+ (entry.target as HTMLElement).dataset.pageNumber
420
+ );
421
+ if (!Number.isFinite(pageNumber)) {
422
+ continue;
423
+ }
424
+ if (entry.isIntersecting) {
425
+ next.add(pageNumber);
426
+ } else {
427
+ next.delete(pageNumber);
428
+ }
429
+ }
430
+ // The exempt batch closes at the first visible-set mutation *after* it
431
+ // has admitted >= 1 page. Requiring a prior admission keeps the cold
432
+ // start correct (there, this mutation is what makes the initial pages
433
+ // active); requiring a subsequent mutation keeps later scroll entries
434
+ // out of a batch that is already serving.
435
+ if (!setsEqual(prev, next)) {
436
+ if (epochBatchOpenRef.current && epochAdmittedRef.current > 0) {
437
+ epochBatchOpenRef.current = false;
438
+ }
439
+ // Genuine visible-set churn restarts the quiescence window.
440
+ if (pendingTimerRef.current !== null) {
441
+ clearTimeout(pendingTimerRef.current);
442
+ pendingTimerRef.current = setTimeout(() => {
443
+ void flushPendingRef.current?.(epochSeqRef.current);
444
+ }, SCROLL_QUIESCENCE_MS);
445
+ }
446
+ }
447
+ return next;
448
+ });
449
+ },
450
+ { root: null, rootMargin: "200px 0px", threshold: 0.01 }
451
+ );
452
+ observerRef.current = observer;
453
+ for (const el of elementsRef.current.values()) {
454
+ observer.observe(el);
455
+ }
456
+ return () => {
457
+ observer.disconnect();
458
+ };
459
+ }, [numPages, IntersectionObserverImpl]);
460
+
461
+ // Propagate visible set into slot.visible / slot.active.
462
+ useEffect(() => {
463
+ syncSlotWindow(visiblePages);
464
+ }, [visiblePages, syncSlotWindow]);
465
+
466
+ const observePage = useCallback(
467
+ (pageNumber: number, el: HTMLElement | null) => {
468
+ const prev = elementsRef.current.get(pageNumber);
469
+ if (prev && observerRef.current) {
470
+ observerRef.current.unobserve(prev);
471
+ }
472
+ if (!el) {
473
+ elementsRef.current.delete(pageNumber);
474
+ return;
475
+ }
476
+ el.dataset.pageNumber = String(pageNumber);
477
+ elementsRef.current.set(pageNumber, el);
478
+ observerRef.current?.observe(el);
479
+ },
480
+ []
481
+ );
482
+
483
+ /**
484
+ * Single-owner cancel for a page's in-flight task.
485
+ * Concurrent callers share one cancelClaim promise; at most one renderCancel
486
+ * and one cancelled/failed settle per taskId.
487
+ */
488
+ const claimCancelInFlight = useCallback(
489
+ async (pageNumber: number): Promise<void> => {
490
+ const cached = cacheRef.current.get(pageNumber);
491
+ if (!cached) {
492
+ return;
493
+ }
494
+ if (!cached.task || cached.settled || !cached.taskId) {
495
+ cached.task = null;
496
+ return;
497
+ }
498
+ if (cached.cancelClaim) {
499
+ await cached.cancelClaim;
500
+ return;
501
+ }
502
+ if (hasSettled(cached.taskId)) {
503
+ cached.task = null;
504
+ cached.settled = true;
505
+ return;
506
+ }
507
+
508
+ const { task, taskId, startGenId } = cached;
509
+ if (startGenId == null || !docId || !task || !taskId) {
510
+ return;
511
+ }
512
+
513
+ cached.cancelClaim = (async () => {
514
+ // Claim is exclusive — record cancel once.
515
+ if (!hasSettled(taskId)) {
516
+ metrics.recordRenderCancel({
517
+ docId,
518
+ pageNumber,
519
+ taskId,
520
+ genId: startGenId,
521
+ });
522
+ }
523
+ try {
524
+ task.cancel();
525
+ } catch {
526
+ // ignore
527
+ }
528
+ try {
529
+ await task.promise;
530
+ if (!hasSettled(taskId)) {
531
+ metrics.recordRenderSettle({
532
+ docId,
533
+ pageNumber,
534
+ taskId,
535
+ genId: startGenId,
536
+ outcome: "cancelled",
537
+ });
538
+ markSettled(taskId);
539
+ }
540
+ } catch (err) {
541
+ if (hasSettled(taskId)) {
542
+ // already recorded
543
+ } else if (isRenderingCancelled(err)) {
544
+ metrics.recordRenderSettle({
545
+ docId,
546
+ pageNumber,
547
+ taskId,
548
+ genId: startGenId,
549
+ outcome: "cancelled",
550
+ });
551
+ markSettled(taskId);
552
+ } else {
553
+ metrics.recordRenderSettle({
554
+ docId,
555
+ pageNumber,
556
+ taskId,
557
+ genId: startGenId,
558
+ outcome: "failed",
559
+ });
560
+ markSettled(taskId);
561
+ }
562
+ }
563
+ const still = cacheRef.current.get(pageNumber);
564
+ if (still?.taskId === taskId) {
565
+ still.task = null;
566
+ still.settled = true;
567
+ still.cancelClaim = null;
568
+ }
569
+ })();
570
+
571
+ await cached.cancelClaim;
572
+ },
573
+ [docId, metrics, hasSettled, markSettled, isRenderingCancelled]
574
+ );
575
+
576
+ const cancelAndCleanup = useCallback(
577
+ async (pageNumber: number): Promise<void> => {
578
+ const cached = cacheRef.current.get(pageNumber);
579
+ if (!cached) {
580
+ canvasRef.current.delete(pageNumber);
581
+ return;
582
+ }
583
+ await claimCancelInFlight(pageNumber);
584
+ const still = cacheRef.current.get(pageNumber);
585
+ if (!still) {
586
+ canvasRef.current.delete(pageNumber);
587
+ return;
588
+ }
589
+ try {
590
+ still.page.cleanup();
591
+ } catch {
592
+ // ignore
593
+ }
594
+ if (docId) {
595
+ metrics.recordPageCleanup({ docId, pageNumber });
596
+ }
597
+ if (still.canvas) {
598
+ zeroCanvasBacking(still.canvas);
599
+ }
600
+ cacheRef.current.delete(pageNumber);
601
+ canvasRef.current.delete(pageNumber);
602
+ setLiveCanvasCount(cacheRef.current.size);
603
+ setSlots((prev) =>
604
+ prev.map((s) =>
605
+ s.pageNumber === pageNumber ? { ...s, rendered: false } : s
606
+ )
607
+ );
608
+ },
609
+ [claimCancelInFlight, docId, metrics]
610
+ );
611
+
612
+ // Evict pages outside the live window / over ceiling.
613
+ useEffect(() => {
614
+ if (!docId || disposedRef.current) {
615
+ return;
616
+ }
617
+ const active = computeActiveSet(visiblePages, numPages);
618
+ const live = [...cacheRef.current.keys()];
619
+ const outside = live.filter((p) => !active.has(p));
620
+ const overCeiling = live.length > LIVE_CANVAS_CEILING;
621
+
622
+ if (outside.length === 0 && !overCeiling) {
623
+ return;
624
+ }
625
+
626
+ const dist = (p: number) => {
627
+ let min = Number.POSITIVE_INFINITY;
628
+ for (const v of visiblePages) {
629
+ min = Math.min(min, Math.abs(v - p));
630
+ }
631
+ if (!Number.isFinite(min) && active.size > 0) {
632
+ for (const v of active) {
633
+ min = Math.min(min, Math.abs(v - p));
634
+ }
635
+ }
636
+ return min;
637
+ };
638
+
639
+ const toEvict = [
640
+ ...outside.sort((a, b) => dist(b) - dist(a)),
641
+ ...live.filter((p) => active.has(p)).sort((a, b) => dist(b) - dist(a)),
642
+ ];
643
+
644
+ let remaining = cacheRef.current.size;
645
+ for (const p of toEvict) {
646
+ if (!cacheRef.current.has(p)) {
647
+ continue;
648
+ }
649
+ const mustEvictOutside = !active.has(p);
650
+ const mustEvictCeiling = remaining > LIVE_CANVAS_CEILING;
651
+ if (!mustEvictOutside && !mustEvictCeiling) {
652
+ break;
653
+ }
654
+ void cancelAndCleanup(p);
655
+ remaining -= 1;
656
+ }
657
+ }, [visiblePages, numPages, docId, cancelAndCleanup]);
658
+
659
+ // Gen bump: cancel in-flight tasks for the old gen only (no synthetic cancel).
660
+ useEffect(() => {
661
+ if (!docId) {
662
+ return;
663
+ }
664
+ for (const [pageNumber, cached] of cacheRef.current) {
665
+ if (
666
+ cached.task &&
667
+ !cached.settled &&
668
+ cached.startGenId != null &&
669
+ cached.startGenId !== genId
670
+ ) {
671
+ void claimCancelInFlight(pageNumber);
672
+ }
673
+ }
674
+ }, [genId, docId, claimCancelInFlight]);
675
+
676
+ /**
677
+ * Ungated render path — today's render body verbatim. Never consults the
678
+ * admission gate, so a pending flush can never re-defer recursively.
679
+ * `admittedEpochSeq` is the token admission was decided under; it is
680
+ * revalidated after every await alongside the existing dispose/gen/active
681
+ * re-checks, and a mismatch abandons the attempt without starting a render.
682
+ */
683
+ const startRenderAdmitted = useCallback(
684
+ async (
685
+ pageNumber: number,
686
+ canvas: HTMLCanvasElement | null,
687
+ admittedEpochSeq: number
688
+ ) => {
689
+ if (
690
+ !doc ||
691
+ !docId ||
692
+ !canvas ||
693
+ pageNumber < 1 ||
694
+ pageNumber > numPages ||
695
+ disposedRef.current
696
+ ) {
697
+ return;
698
+ }
699
+
700
+ // Only pages in the live window (visible ± overscan) may render.
701
+ const active = computeActiveSet(visibleRef.current, numPages);
702
+ if (!active.has(pageNumber)) {
703
+ canvasRef.current.delete(pageNumber);
704
+ return;
705
+ }
706
+
707
+ canvasRef.current.set(pageNumber, canvas);
708
+
709
+ const currentGen = genIdRef.current;
710
+ const currentScale = scaleRef.current;
711
+ const requestToken = Symbol(`pdf-page-${pageNumber}`);
712
+ latestRequestRef.current.set(pageNumber, requestToken);
713
+
714
+ /**
715
+ * Full admission identity: {docId, genId, epochSeq, pageNumber, canvas}
716
+ * plus live-window membership and DOM attachment. Re-checked after EVERY
717
+ * await — a canvas replaced or detached while `getPage` was pending must
718
+ * never receive a renderStart, a hidden metric event, or a backing-store
719
+ * allocation.
720
+ */
721
+ const identityStillValid = (): boolean =>
722
+ !disposedRef.current &&
723
+ genIdRef.current === currentGen &&
724
+ scaleRef.current === currentScale &&
725
+ epochSeqRef.current === admittedEpochSeq &&
726
+ latestRequestRef.current.get(pageNumber) === requestToken &&
727
+ computeActiveSet(visibleRef.current, numPages).has(pageNumber) &&
728
+ canvasRef.current.get(pageNumber) === canvas &&
729
+ canvas.isConnected;
730
+
731
+ /**
732
+ * Same identity, used after a cancel we ourselves initiated. Those paths
733
+ * intentionally drop the page's `canvasRef` entry, so "no entry" means
734
+ * "still ours"; only a *different* canvas claiming the page is a genuine
735
+ * replacement. Ownership is re-asserted so the strict check above holds
736
+ * at the post-`getPage` checkpoint.
737
+ */
738
+ const identityValidAfterOwnCancel = (): boolean => {
739
+ if (
740
+ disposedRef.current ||
741
+ genIdRef.current !== currentGen ||
742
+ scaleRef.current !== currentScale ||
743
+ epochSeqRef.current !== admittedEpochSeq ||
744
+ latestRequestRef.current.get(pageNumber) !== requestToken ||
745
+ !computeActiveSet(visibleRef.current, numPages).has(pageNumber) ||
746
+ !canvas.isConnected
747
+ ) {
748
+ return false;
749
+ }
750
+ const owner = canvasRef.current.get(pageNumber);
751
+ if (owner !== undefined && owner !== canvas) {
752
+ return false;
753
+ }
754
+ canvasRef.current.set(pageNumber, canvas);
755
+ return true;
756
+ };
757
+
758
+ // Evict farthest until this uncached page can reserve one live-canvas
759
+ // slot. Reservations happen before getPage(), so concurrent cold-window
760
+ // admissions cannot all observe the same under-ceiling cache size.
761
+ while (!cacheRef.current.has(pageNumber)) {
762
+ const alreadyReserved = reservationsRef.current.has(pageNumber);
763
+ const usedByOtherPages =
764
+ cacheRef.current.size +
765
+ reservationsRef.current.size -
766
+ (alreadyReserved ? 1 : 0);
767
+ if (usedByOtherPages < LIVE_CANVAS_CEILING) {
768
+ break;
769
+ }
770
+ let farthest = -1;
771
+ let farthestDist = -1;
772
+ for (const p of cacheRef.current.keys()) {
773
+ let min = Number.POSITIVE_INFINITY;
774
+ for (const v of visibleRef.current) {
775
+ min = Math.min(min, Math.abs(v - p));
776
+ }
777
+ if (min > farthestDist) {
778
+ farthestDist = min;
779
+ farthest = p;
780
+ }
781
+ }
782
+ if (farthest < 1) {
783
+ return;
784
+ }
785
+ await cancelAndCleanup(farthest);
786
+ if (!identityValidAfterOwnCancel()) {
787
+ return;
788
+ }
789
+ }
790
+
791
+ const existing = cacheRef.current.get(pageNumber);
792
+ // In-flight at a different gen → await single-owner cancel, then replace.
793
+ if (
794
+ existing?.task &&
795
+ !existing.settled &&
796
+ existing.startGenId != null &&
797
+ (existing.startGenId !== currentGen ||
798
+ existing.startScale !== currentScale)
799
+ ) {
800
+ await claimCancelInFlight(pageNumber);
801
+ if (!identityValidAfterOwnCancel()) {
802
+ return;
803
+ }
804
+ }
805
+
806
+ const afterCancel = cacheRef.current.get(pageNumber);
807
+ if (
808
+ afterCancel &&
809
+ afterCancel.startGenId === currentGen &&
810
+ afterCancel.startScale === currentScale &&
811
+ afterCancel.canvas === canvas &&
812
+ (afterCancel.task || afterCancel.settled)
813
+ ) {
814
+ if (afterCancel.settled && !afterCancel.task) {
815
+ return;
816
+ }
817
+ if (afterCancel.task && !afterCancel.settled) {
818
+ return;
819
+ }
820
+ }
821
+
822
+ if (!cacheRef.current.has(pageNumber)) {
823
+ reservationsRef.current.set(pageNumber, requestToken);
824
+ }
825
+ const releaseReservation = (): void => {
826
+ if (reservationsRef.current.get(pageNumber) === requestToken) {
827
+ reservationsRef.current.delete(pageNumber);
828
+ }
829
+ };
830
+
831
+ let page: PDFPageProxy;
832
+ const still = cacheRef.current.get(pageNumber);
833
+ if (still?.page) {
834
+ page = still.page;
835
+ } else {
836
+ try {
837
+ page = await doc.getPage(pageNumber);
838
+ } catch (error) {
839
+ releaseReservation();
840
+ setPageError(classifyPdfError(error));
841
+ return;
842
+ }
843
+ }
844
+
845
+ // Full identity re-check after page acquisition, BEFORE any slot write,
846
+ // metric event, or backing-store allocation.
847
+ if (!identityStillValid()) {
848
+ releaseReservation();
849
+ return;
850
+ }
851
+
852
+ // Release the admission on every pre-commit path. Geometry/DPR helpers
853
+ // are third-party boundaries too; an exception there must not consume a
854
+ // permanent canvas reservation and deadlock later pages at the ceiling.
855
+ const rollbackPreStart = () => {
856
+ releaseReservation();
857
+ zeroCanvasBacking(canvas);
858
+ try {
859
+ page.cleanup();
860
+ } catch {
861
+ // ignore
862
+ }
863
+ if (docId) {
864
+ metrics.recordPageCleanup({ docId, pageNumber });
865
+ }
866
+ cacheRef.current.delete(pageNumber);
867
+ setLiveCanvasCount(cacheRef.current.size);
868
+ };
869
+
870
+ let prepared: {
871
+ viewport: ReturnType<PDFPageProxy["getViewport"]>;
872
+ renderViewport: ReturnType<PDFPageProxy["getViewport"]>;
873
+ effective: ReturnType<typeof defaultComputeEffectiveScale>;
874
+ };
875
+ try {
876
+ const viewport = page.getViewport({ scale: currentScale });
877
+ const effective = computeEffectiveScale({
878
+ zoom: currentScale,
879
+ devicePixelRatio,
880
+ cssWidth: viewport.width,
881
+ cssHeight: viewport.height,
882
+ });
883
+ prepared = {
884
+ viewport,
885
+ effective,
886
+ renderViewport: page.getViewport({
887
+ scale: effective.renderScale,
888
+ }),
889
+ };
890
+ } catch (error) {
891
+ rollbackPreStart();
892
+ setPageError(classifyPdfError(error));
893
+ return;
894
+ }
895
+ const { viewport, effective, renderViewport } = prepared;
896
+ setSlots((prev) =>
897
+ prev.map((s) =>
898
+ s.pageNumber === pageNumber
899
+ ? { ...s, width: viewport.width, height: viewport.height }
900
+ : s
901
+ )
902
+ );
903
+
904
+ const taskId = metrics.mintTaskId();
905
+ const startGenId = currentGen;
906
+
907
+ canvas.width = effective.canvasWidth;
908
+ canvas.height = effective.canvasHeight;
909
+ canvas.style.width = `${viewport.width}px`;
910
+ canvas.style.height = `${viewport.height}px`;
911
+ markLiveBacking(canvas);
912
+
913
+ const ctx = canvas.getContext("2d");
914
+ if (!ctx) {
915
+ rollbackPreStart();
916
+ return;
917
+ }
918
+
919
+ let task: RenderTask;
920
+ try {
921
+ task = page.render({
922
+ canvasContext: ctx,
923
+ viewport: renderViewport,
924
+ canvas,
925
+ } as never);
926
+ } catch (error) {
927
+ rollbackPreStart();
928
+ setPageError(classifyPdfError(error));
929
+ return;
930
+ }
931
+
932
+ // Logical committed scale (not DPR/effective render scale).
933
+ metrics.recordRenderStart({
934
+ docId,
935
+ pageNumber,
936
+ taskId,
937
+ genId: startGenId,
938
+ scale: currentScale,
939
+ canvasWidth: effective.canvasWidth,
940
+ canvasHeight: effective.canvasHeight,
941
+ });
942
+
943
+ cacheRef.current.set(pageNumber, {
944
+ page,
945
+ task,
946
+ taskId,
947
+ startGenId,
948
+ startScale: currentScale,
949
+ canvas,
950
+ settled: false,
951
+ cancelClaim: null,
952
+ });
953
+ releaseReservation();
954
+ setPageError(null);
955
+ setLiveCanvasCount(cacheRef.current.size);
956
+
957
+ try {
958
+ await task.promise;
959
+ if (hasSettled(taskId)) {
960
+ return;
961
+ }
962
+ const entry = cacheRef.current.get(pageNumber);
963
+ if (entry?.taskId === taskId && !entry.settled) {
964
+ metrics.recordRenderSettle({
965
+ docId,
966
+ pageNumber,
967
+ taskId,
968
+ genId: startGenId,
969
+ outcome: "completed",
970
+ scale: currentScale,
971
+ });
972
+ markSettled(taskId);
973
+ entry.task = null;
974
+ entry.settled = true;
975
+ setSlots((prev) =>
976
+ prev.map((s) =>
977
+ s.pageNumber === pageNumber ? { ...s, rendered: true } : s
978
+ )
979
+ );
980
+ }
981
+ } catch (err) {
982
+ if (hasSettled(taskId)) {
983
+ return;
984
+ }
985
+ if (isRenderingCancelled(err)) {
986
+ metrics.recordRenderSettle({
987
+ docId,
988
+ pageNumber,
989
+ taskId,
990
+ genId: startGenId,
991
+ outcome: "cancelled",
992
+ });
993
+ } else {
994
+ metrics.recordRenderSettle({
995
+ docId,
996
+ pageNumber,
997
+ taskId,
998
+ genId: startGenId,
999
+ outcome: "failed",
1000
+ });
1001
+ // Failed (non-cancel) renders: dispose page resources safely.
1002
+ const entry = cacheRef.current.get(pageNumber);
1003
+ if (entry?.taskId === taskId) {
1004
+ try {
1005
+ entry.page.cleanup();
1006
+ } catch {
1007
+ // ignore
1008
+ }
1009
+ if (entry.canvas) {
1010
+ zeroCanvasBacking(entry.canvas);
1011
+ }
1012
+ if (docId) {
1013
+ metrics.recordPageCleanup({ docId, pageNumber });
1014
+ }
1015
+ cacheRef.current.delete(pageNumber);
1016
+ setLiveCanvasCount(cacheRef.current.size);
1017
+ }
1018
+ }
1019
+ markSettled(taskId);
1020
+ const entry = cacheRef.current.get(pageNumber);
1021
+ if (entry?.taskId === taskId) {
1022
+ entry.task = null;
1023
+ entry.settled = true;
1024
+ }
1025
+ }
1026
+ },
1027
+ // genId must be a dependency: PdfPageView starts renders via an effect keyed
1028
+ // on onRender identity. Zoom/fit gen bumps cancel in-flight work in the gen
1029
+ // effect, but active stays true and scale alone is not a reliable re-entry
1030
+ // signal under all fit modes. Changing ensureRendered identity on gen commit
1031
+ // re-invokes onRender so a higher-gen replacement starts after cancel settles
1032
+ // (ordering: start < cancel < cancelled settle < higher-gen start).
1033
+ [
1034
+ doc,
1035
+ docId,
1036
+ numPages,
1037
+ genId,
1038
+ devicePixelRatio,
1039
+ metrics,
1040
+ cancelAndCleanup,
1041
+ claimCancelInFlight,
1042
+ hasSettled,
1043
+ markSettled,
1044
+ computeEffectiveScale,
1045
+ isRenderingCancelled,
1046
+ ]
1047
+ );
1048
+
1049
+ /**
1050
+ * Flush the pending set. Captures the epoch it was armed under and no-ops when
1051
+ * stale; atomically claims the map (copy, then clear) before iterating; and
1052
+ * processes entries sequentially so ceiling eviction cannot race.
1053
+ */
1054
+ const flushPending = useCallback(
1055
+ async (armedEpochSeq: number): Promise<void> => {
1056
+ pendingTimerRef.current = null;
1057
+ if (disposedRef.current || epochSeqRef.current !== armedEpochSeq) {
1058
+ return;
1059
+ }
1060
+ const claimed = [...pendingRef.current.values()];
1061
+ pendingRef.current.clear();
1062
+ for (const entry of claimed) {
1063
+ if (disposedRef.current || epochSeqRef.current !== armedEpochSeq) {
1064
+ return;
1065
+ }
1066
+ if (entry.docId !== docId || entry.genId !== genIdRef.current) {
1067
+ continue;
1068
+ }
1069
+ const active = computeActiveSet(visibleRef.current, numPages);
1070
+ if (!active.has(entry.pageNumber)) {
1071
+ continue;
1072
+ }
1073
+ if (
1074
+ canvasRef.current.get(entry.pageNumber) !== entry.canvas ||
1075
+ !entry.canvas.isConnected
1076
+ ) {
1077
+ continue;
1078
+ }
1079
+ const cached = cacheRef.current.get(entry.pageNumber);
1080
+ if (
1081
+ cached?.task &&
1082
+ !cached.settled &&
1083
+ cached.startGenId === genIdRef.current &&
1084
+ cached.startScale === entry.scale
1085
+ ) {
1086
+ continue;
1087
+ }
1088
+ if (entry.scale !== scaleRef.current) {
1089
+ continue;
1090
+ }
1091
+ await startRenderAdmitted(
1092
+ entry.pageNumber,
1093
+ entry.canvas,
1094
+ armedEpochSeq
1095
+ );
1096
+ }
1097
+ },
1098
+ [docId, numPages, startRenderAdmitted]
1099
+ );
1100
+
1101
+ flushPendingRef.current = flushPending;
1102
+
1103
+ /**
1104
+ * Guards + admission decision. Admission is decided synchronously at entry,
1105
+ * before any await, and carries an `epochSeq` token.
1106
+ */
1107
+ const ensureRendered = useCallback(
1108
+ async (pageNumber: number, canvas: HTMLCanvasElement | null) => {
1109
+ if (
1110
+ !doc ||
1111
+ !docId ||
1112
+ !canvas ||
1113
+ pageNumber < 1 ||
1114
+ pageNumber > numPages ||
1115
+ disposedRef.current
1116
+ ) {
1117
+ return;
1118
+ }
1119
+
1120
+ // Only pages in the live window (visible ± overscan) may render.
1121
+ const active = computeActiveSet(visibleRef.current, numPages);
1122
+ if (!active.has(pageNumber)) {
1123
+ canvasRef.current.delete(pageNumber);
1124
+ return;
1125
+ }
1126
+ canvasRef.current.set(pageNumber, canvas);
1127
+
1128
+ const admittedEpochSeq = epochSeqRef.current;
1129
+ if (epochBatchOpenRef.current) {
1130
+ epochAdmittedRef.current += 1;
1131
+ await startRenderAdmitted(pageNumber, canvas, admittedEpochSeq);
1132
+ return;
1133
+ }
1134
+
1135
+ // Deferred: schedule, and emit no metric event unless later admitted.
1136
+ pendingRef.current.set(pageNumber, {
1137
+ docId,
1138
+ genId: genIdRef.current,
1139
+ epochSeq: admittedEpochSeq,
1140
+ pageNumber,
1141
+ canvas,
1142
+ scale: scaleRef.current,
1143
+ });
1144
+ // Arm once per pending batch. The quiescence being waited on is that of
1145
+ // the VISIBLE SET (the observer re-arms below on genuine churn); resetting
1146
+ // the timer on every ensureRendered call would let a component that
1147
+ // re-invokes it starve the flush indefinitely.
1148
+ if (pendingTimerRef.current === null) {
1149
+ pendingTimerRef.current = setTimeout(() => {
1150
+ void flushPending(admittedEpochSeq);
1151
+ }, SCROLL_QUIESCENCE_MS);
1152
+ }
1153
+ },
1154
+ [doc, docId, numPages, startRenderAdmitted, flushPending]
1155
+ );
1156
+
1157
+ const disposeAll = useCallback(async (): Promise<void> => {
1158
+ clearPending();
1159
+ if (disposePromiseRef.current) {
1160
+ await disposePromiseRef.current;
1161
+ return;
1162
+ }
1163
+ disposePromiseRef.current = (async () => {
1164
+ disposedRef.current = true;
1165
+ const pages = [...cacheRef.current.keys()];
1166
+ await Promise.all(pages.map((p) => cancelAndCleanup(p)));
1167
+ canvasRef.current.clear();
1168
+ reservationsRef.current.clear();
1169
+ latestRequestRef.current.clear();
1170
+ setLiveCanvasCount(0);
1171
+ })();
1172
+ try {
1173
+ await disposePromiseRef.current;
1174
+ } finally {
1175
+ disposePromiseRef.current = null;
1176
+ // Allow re-use if doc is remounted with same hook instance.
1177
+ disposedRef.current = false;
1178
+ }
1179
+ }, [cancelAndCleanup, clearPending]);
1180
+
1181
+ // Trigger disposal on unmount / doc change (does not await — use disposeAll).
1182
+ useEffect(() => {
1183
+ return () => {
1184
+ void disposeAll();
1185
+ };
1186
+ }, [doc, disposeAll]);
1187
+
1188
+ return {
1189
+ slots,
1190
+ error: pageError,
1191
+ liveCanvasCount,
1192
+ observePage,
1193
+ ensureRendered,
1194
+ scale,
1195
+ disposeAll,
1196
+ };
1197
+ }