mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9e21e466b | ||
|
|
62fe11af48 | ||
|
|
62967c9065 | ||
|
|
61b67ecdaf | ||
|
|
4164bc15c6 | ||
|
|
163041af03 | ||
|
|
f51ea06cbc | ||
|
|
929e8f6269 | ||
|
|
e5f8183320 | ||
|
|
3582e82d99 | ||
|
|
5ce9259ddf | ||
|
|
93c646f704 | ||
|
|
822bbaf7c3 | ||
|
|
9fb7e531b1 | ||
|
|
b21187803d | ||
|
|
b4b9a149fb | ||
|
|
592dc1a1a2 | ||
|
|
b8ee6e589d | ||
|
|
f3d4ead177 | ||
|
|
d0793ea8bb | ||
|
|
bdccc90e80 | ||
|
|
69f642f87d | ||
|
|
d6168a5c55 |
+20
-12
@@ -10,6 +10,18 @@ import tsconfigPaths from "vite-tsconfig-paths";
|
||||
* the portal layer at editor/src/portal/). MDX docs pages live in
|
||||
* editor/src/portal/docs/.
|
||||
*/
|
||||
/**
|
||||
* Editor stories import via `@app/*` (proprietary→core fallback), `@core/*` and
|
||||
* `@proprietary/*`. Resolve them exactly the way the editor's own build does -
|
||||
* through vite-tsconfig-paths against the proprietary vite tsconfig - so the
|
||||
* shared Storybook can host editor components without duplicating the alias map
|
||||
* here. Built per pass: the main bundle and the worker bundle each need their own.
|
||||
*/
|
||||
const editorPathAliases = () =>
|
||||
tsconfigPaths({
|
||||
projects: [resolve(__dirname, "../editor/tsconfig.proprietary.vite.json")],
|
||||
});
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
"../editor/src/portal/**/*.mdx",
|
||||
@@ -47,19 +59,15 @@ const config: StorybookConfig = {
|
||||
// than a relative path.
|
||||
"@public": resolve(__dirname, "../editor/public"),
|
||||
};
|
||||
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
|
||||
// @proprietary/*. Resolve them exactly the way the editor's own build does —
|
||||
// through vite-tsconfig-paths against the proprietary vite tsconfig — so the
|
||||
// shared Storybook can host editor components without duplicating the alias
|
||||
// map here.
|
||||
config.plugins = config.plugins ?? [];
|
||||
config.plugins.push(
|
||||
tsconfigPaths({
|
||||
projects: [
|
||||
resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
config.plugins.push(editorPathAliases());
|
||||
// Worker bundles are a separate Rollup pass and do NOT inherit `plugins`, so
|
||||
// without this a worker importing @app/* fails to resolve while the same
|
||||
// import works everywhere else. Mirrors editor/vite.config.ts.
|
||||
config.worker = {
|
||||
...(config.worker ?? {}),
|
||||
plugins: () => [editorPathAliases()],
|
||||
};
|
||||
// Point apiClient.saas at a mock origin so the SaaS-backed billing stories
|
||||
// (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and
|
||||
// their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host
|
||||
|
||||
@@ -17,9 +17,12 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
*
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
/** Shared by every stubbed project so a spec sees one layout on all engines. */
|
||||
const STUBBED_VIEWPORT = { width: 1920, height: 1080 };
|
||||
|
||||
const chromiumViewport = {
|
||||
...devices["Desktop Chrome"],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
viewport: STUBBED_VIEWPORT,
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
@@ -55,7 +58,8 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
projects: [
|
||||
// Stubbed - no backend required, chromium-only for CI speed
|
||||
// Stubbed - no backend required. The chromium arm of the cross-browser
|
||||
// set below; CI fans all three out, one job per engine.
|
||||
{
|
||||
name: "stubbed",
|
||||
testDir: "./src/core/tests/stubbed",
|
||||
@@ -93,16 +97,17 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
|
||||
// Cross-browser coverage for the stubbed suite (opt-in locally)
|
||||
// Cross-browser coverage for the stubbed suite. Same viewport as `stubbed`,
|
||||
// or a layout difference here reads as an engine outage.
|
||||
{
|
||||
name: "stubbed-firefox",
|
||||
testDir: "./src/core/tests/stubbed",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
use: { ...devices["Desktop Firefox"], viewport: STUBBED_VIEWPORT },
|
||||
},
|
||||
{
|
||||
name: "stubbed-webkit",
|
||||
testDir: "./src/core/tests/stubbed",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
use: { ...devices["Desktop Safari"], viewport: STUBBED_VIEWPORT },
|
||||
},
|
||||
],
|
||||
|
||||
|
||||
@@ -3883,6 +3883,8 @@ addFiles = "Add files"
|
||||
addingFiles = "Adding files…"
|
||||
collapse = "Collapse sidebar"
|
||||
customizeGroups = "Customize groups"
|
||||
dataLostBody = "This browser lost this file's contents. Upload it again to keep working with it."
|
||||
dataLostTitle = "File data is unavailable"
|
||||
dropHint = "Open files to get started"
|
||||
dropToAdd = "Drop files to add"
|
||||
expand = "Expand sidebar"
|
||||
@@ -3903,6 +3905,8 @@ viewAll = "View all {{count}} files"
|
||||
|
||||
[fileSidebar.fileItem]
|
||||
closeViewer = "Close viewer"
|
||||
dataLost = "Data lost"
|
||||
dataLostTooltip = "This browser lost this file's contents. Upload it again to keep working with it."
|
||||
delete = "Delete"
|
||||
moreActions = "More actions"
|
||||
openInViewer = "Open in viewer"
|
||||
|
||||
@@ -300,6 +300,10 @@ const FileEditorThumbnail = ({
|
||||
const [showVersionHistory, setShowVersionHistory] = useState(false);
|
||||
|
||||
const policyEnforcing = policies.some((p) => p.enforcing);
|
||||
// The overlay swallows clicks, so a run that never settles would leave the card
|
||||
// unusable with no way out. Dismissible, like the viewer's; resets per run.
|
||||
const [enforcingDismissed, setEnforcingDismissed] = useState(false);
|
||||
if (!policyEnforcing && enforcingDismissed) setEnforcingDismissed(false);
|
||||
// The policy currently enforcing, so the overlay's icon/spinner match that
|
||||
// policy's badge instead of a fixed blue.
|
||||
const enforcingPolicy = policies.find((p) => p.enforcing);
|
||||
@@ -548,8 +552,9 @@ const FileEditorThumbnail = ({
|
||||
|
||||
{/* Policy enforcement overlay — shown while any policy is in-flight */}
|
||||
<PolicyEnforcingOverlay
|
||||
enforcing={policyEnforcing}
|
||||
enforcing={policyEnforcing && !enforcingDismissed}
|
||||
zIndex={2}
|
||||
onDismiss={() => setEnforcingDismissed(true)}
|
||||
accentVar={enforcingPolicy?.accentColor}
|
||||
categoryId={enforcingPolicy?.id}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, Suspense, lazy } from "react";
|
||||
import { Box, Loader, Center } from "@mantine/core";
|
||||
import { Box, Loader, Center, Stack, Text } from "@mantine/core";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
@@ -12,6 +12,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useSigningOverlay } from "@app/contexts/SigningOverlayContext";
|
||||
import { useCookieConsent } from "@app/hooks/useCookieConsent";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styles from "@app/components/layout/Workbench.module.css";
|
||||
|
||||
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
|
||||
@@ -34,6 +35,7 @@ const FileManagerView = lazy(
|
||||
|
||||
// No props needed - component uses contexts directly
|
||||
export default function Workbench() {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// The consent banner used to be initialised by the footer; the legal links
|
||||
@@ -41,7 +43,7 @@ export default function Workbench() {
|
||||
useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { files: activeFiles } = useAllFiles();
|
||||
const { files: activeFiles, fileIds } = useAllFiles();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
@@ -136,6 +138,20 @@ export default function Workbench() {
|
||||
}
|
||||
|
||||
if (activeFiles.length === 0) {
|
||||
// Files are open but their bytes are still loading (a cold PDF engine can
|
||||
// take seconds). Showing the drop zone here reads as "the click did nothing".
|
||||
if (fileIds.length > 0) {
|
||||
return (
|
||||
<Center h="100%" w="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<Loader size="lg" />
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("fileManager.loadingFiles", "Loading files...")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return <LandingPage />;
|
||||
}
|
||||
|
||||
|
||||
@@ -201,13 +201,27 @@ export function FileSelectorPicker({
|
||||
setSortDir(lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]));
|
||||
}, [isOpen]);
|
||||
|
||||
// Load saved files when the saved tab is active and the picker is open
|
||||
// Load saved files when the saved tab is active and the picker is open.
|
||||
// Cancellable: storage reads can be slow or reject, and outlive the popover.
|
||||
useEffect(() => {
|
||||
if (activeTab !== "saved" || !isOpen) return;
|
||||
let cancelled = false;
|
||||
setSavedLoading(true);
|
||||
loadRecentFiles()
|
||||
.then(setSavedStubs)
|
||||
.finally(() => setSavedLoading(false));
|
||||
.then((stubs) => {
|
||||
if (!cancelled) setSavedStubs(stubs);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) setSavedStubs([]);
|
||||
console.warn("Failed to load saved files for the picker:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSavedLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setSavedLoading(false);
|
||||
};
|
||||
}, [activeTab, isOpen, loadRecentFiles]);
|
||||
|
||||
const workbenchIdSet = useMemo(
|
||||
@@ -541,7 +555,9 @@ export function FileSelectorPicker({
|
||||
</div>
|
||||
|
||||
<ScrollArea h={260} className={styles.list}>
|
||||
{savedLoading ? (
|
||||
{/* Workbench renders from memory, so a stuck storage read must not
|
||||
spin it too. */}
|
||||
{savedLoading && activeTab === "saved" ? (
|
||||
<div className={styles.emptyState}>
|
||||
<Loader size="sm" />
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,8 @@ import {
|
||||
deleteServerFile,
|
||||
type DeleteScope,
|
||||
} from "@app/services/serverStorageDelete";
|
||||
import { fileStorage } from "@app/services/fileStorage";
|
||||
import { fileStorage, onRecordUnreadable } from "@app/services/fileStorage";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { useBulkAddProgress } from "@app/services/bulkAddProgress";
|
||||
import { useFolderMembership } from "@app/hooks/useFolderMembership";
|
||||
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
|
||||
@@ -288,6 +289,19 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
|
||||
// Leaf files = user-visible files (excludes intermediate tool outputs)
|
||||
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
|
||||
// Files whose stored bytes this session PROVED unreadable. Rows render a
|
||||
// "data lost" state instead of pretending the file can open; storage keeps
|
||||
// the record so a reload re-tests it.
|
||||
const [lostFileIds, setLostFileIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
useEffect(
|
||||
() =>
|
||||
onRecordUnreadable((fileId) =>
|
||||
setLostFileIds((prev) => new Set(prev).add(fileId as string)),
|
||||
),
|
||||
[],
|
||||
);
|
||||
const [stubsLoaded, setStubsLoaded] = useState(false);
|
||||
// Kebab "Save to cloud" target; drives BulkUploadToServerModal.
|
||||
const [saveToServerTarget, setSaveToServerTarget] = useState<
|
||||
@@ -306,32 +320,45 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const storageEnabled = config?.storageEnabled === true && !isAnonymous;
|
||||
|
||||
const refreshStubs = useCallback(async () => {
|
||||
// Leaf files from IDB - same source as the file selection modal.
|
||||
const stubs = await indexedDB.loadLeafMetadata();
|
||||
const idbIds = new Set(stubs.map((s) => s.id as string));
|
||||
// `stubsLoaded` gates the spinner, so the `finally` below must set it on
|
||||
// every path - callers never await this, so a rejection goes nowhere.
|
||||
let stubs: StirlingFileStub[] = [];
|
||||
try {
|
||||
// Leaf files from IDB - same source as the file selection modal.
|
||||
stubs = await indexedDB.loadLeafMetadata();
|
||||
} catch (error) {
|
||||
// Carry on with the in-memory workbench files: an unreadable library
|
||||
// should cost the user their history, not the file they're working on.
|
||||
console.error("Failed to read the file library from storage:", error);
|
||||
}
|
||||
|
||||
// Also include workbench files not yet flushed to IDB.
|
||||
const pendingStubs = state.files.ids
|
||||
.map((id) => state.files.byId[id])
|
||||
.filter(
|
||||
(stub): stub is NonNullable<typeof stub> =>
|
||||
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
|
||||
try {
|
||||
const idbIds = new Set(stubs.map((s) => s.id as string));
|
||||
|
||||
// Also include workbench files not yet flushed to IDB.
|
||||
const pendingStubs = state.files.ids
|
||||
.map((id) => state.files.byId[id])
|
||||
.filter(
|
||||
(stub): stub is NonNullable<typeof stub> =>
|
||||
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
|
||||
);
|
||||
|
||||
const allStubs = [...stubs, ...pendingStubs];
|
||||
// A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent.
|
||||
const superseded = new Set(
|
||||
allStubs.map((s) => s.parentFileId as string | undefined),
|
||||
);
|
||||
|
||||
const allStubs = [...stubs, ...pendingStubs];
|
||||
// A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent.
|
||||
const superseded = new Set(
|
||||
allStubs.map((s) => s.parentFileId as string | undefined),
|
||||
);
|
||||
const currentStubs = allStubs.filter(
|
||||
(s) => !superseded.has(s.id as string),
|
||||
);
|
||||
setAllFileStubs(
|
||||
currentStubs.sort(
|
||||
(a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0),
|
||||
),
|
||||
);
|
||||
setStubsLoaded(true);
|
||||
const currentStubs = allStubs.filter(
|
||||
(s) => !superseded.has(s.id as string),
|
||||
);
|
||||
setAllFileStubs(
|
||||
currentStubs.sort(
|
||||
(a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setStubsLoaded(true);
|
||||
}
|
||||
}, [indexedDB, state.files.ids, state.files.byId]);
|
||||
|
||||
// Refresh on mount, workbench changes, or external IndexedDB writes —
|
||||
@@ -370,7 +397,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
setDeleteTarget(stub);
|
||||
return;
|
||||
}
|
||||
await fileActions.removeFiles([fileId], true);
|
||||
// Its superseded versions go too - see orphanedAncestorIds.
|
||||
const orphans = await fileStorage.orphanedAncestorIds([fileId]);
|
||||
await fileActions.removeFiles([fileId, ...orphans], true);
|
||||
await refreshStubs();
|
||||
},
|
||||
[allFileStubs, fileActions, refreshStubs],
|
||||
@@ -388,7 +417,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
await deleteServerFile(stub.remoteStorageId);
|
||||
}
|
||||
if (scope === "device" || scope === "everywhere") {
|
||||
await fileActions.removeFiles([stub.id], true);
|
||||
const orphans = await fileStorage.orphanedAncestorIds([stub.id]);
|
||||
await fileActions.removeFiles([stub.id, ...orphans], true);
|
||||
} else if (scope === "cloud") {
|
||||
// Local copy kept - drop the dead remote pointer so the cloud badge
|
||||
// clears (the sidebar doesn't reconcile with the server itself).
|
||||
@@ -524,6 +554,22 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const stub = allFileStubs.find((s) => s.id === fileId);
|
||||
if (!stub) return;
|
||||
|
||||
// Its bytes are gone; opening it can only fail. Say so instead of a
|
||||
// click that goes nowhere.
|
||||
if (stub.dataUnavailable || lostFileIds.has(fileId as string)) {
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("fileSidebar.dataLostTitle", "File data is unavailable"),
|
||||
body: t(
|
||||
"fileSidebar.dataLostBody",
|
||||
"This browser lost this file's contents. Upload it again to keep working with it.",
|
||||
),
|
||||
expandable: false,
|
||||
durationMs: 6000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// In the Watched Folders view a click sends the file into the open folder
|
||||
// (mirrors how a click toggles a file into the active workbench elsewhere).
|
||||
// On the folder list (no folder open) it's a no-op so browsing isn't disrupted.
|
||||
@@ -578,6 +624,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
},
|
||||
[
|
||||
allFileStubs,
|
||||
lostFileIds,
|
||||
t,
|
||||
state.files.ids,
|
||||
state.ui.selectedFileIds,
|
||||
fileActions,
|
||||
@@ -765,6 +813,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
? state.files.byId[workbenchFileId]?.thumbnailUrl
|
||||
: undefined) || stub.thumbnailUrl;
|
||||
const fileOrigin = getFileOrigin(stub);
|
||||
const dataUnavailable =
|
||||
stub.dataUnavailable === true || lostFileIds.has(stub.id as string);
|
||||
// Key by lineage (originalFileId) so a version swap updates the row in place instead of
|
||||
// remounting. But a 1-input→many-output op (split) yields sibling leaves that share one
|
||||
// originalFileId; those would collide on the key, so fall back to the unique leaf id when a
|
||||
@@ -787,6 +837,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
thumbnailUrl={thumbnailUrl}
|
||||
onClick={handleFileClick}
|
||||
onEyeClick={handleEyeClick}
|
||||
dataUnavailable={dataUnavailable}
|
||||
draggable={isWatchedFoldersActive}
|
||||
onDragStart={handleWatchedFolderDragStart}
|
||||
folders={memberFolders}
|
||||
|
||||
@@ -447,3 +447,13 @@
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* The stored bytes are gone - the row says so instead of pretending to open. */
|
||||
.file-sidebar-datalost-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
color: var(--c-danger);
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import CloudUploadOutlinedIcon from "@mui/icons-material/CloudUploadOutlined";
|
||||
import CloudDoneIcon from "@mui/icons-material/CloudDone";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineOutlined";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import type { FileId } from "@app/types/file";
|
||||
@@ -163,6 +164,9 @@ export interface FileItemProps {
|
||||
onVersionHistory?: (fileId: FileId) => void;
|
||||
/** Whether this file has more than one version (drives the menu item). */
|
||||
hasVersionHistory?: boolean;
|
||||
/** The stored bytes are gone (WebKit lost the blob's backing store). The row
|
||||
* says so instead of pretending the file can open. */
|
||||
dataUnavailable?: boolean;
|
||||
}
|
||||
|
||||
const MAX_VISIBLE_FOLDER_TAGS = 2;
|
||||
@@ -177,6 +181,7 @@ export const FileItem = React.memo(function FileItem({
|
||||
isSelected,
|
||||
isActive,
|
||||
isViewedInViewer,
|
||||
dataUnavailable,
|
||||
thumbnailUrl,
|
||||
onClick,
|
||||
onEyeClick,
|
||||
@@ -294,6 +299,21 @@ export const FileItem = React.memo(function FileItem({
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{dataUnavailable && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
"fileSidebar.fileItem.dataLostTooltip",
|
||||
"This browser lost this file's contents. Upload it again to keep working with it.",
|
||||
)}
|
||||
withArrow
|
||||
position="top"
|
||||
>
|
||||
<span className="file-sidebar-datalost-badge" data-no-select>
|
||||
<ErrorOutlineIcon sx={{ fontSize: "0.85rem" }} />
|
||||
{t("fileSidebar.fileItem.dataLost", "Data lost")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isUploadedToCloud && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
|
||||
@@ -6,6 +6,9 @@ export function PolicyEnforcingOverlay(_props: {
|
||||
accentVar?: string;
|
||||
/** Category of the enforcing policy — picks its icon in the real overlay. */
|
||||
categoryId?: string;
|
||||
/** Shows a dismiss control in the real overlay, so a run that never settles
|
||||
* can't leave the surface underneath permanently unclickable. */
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
IndexedDBProvider,
|
||||
useIndexedDB,
|
||||
} from "@app/contexts/IndexedDBContext";
|
||||
import { onRecordUnreadable } from "@app/services/fileStorage";
|
||||
import { useZipConfirmation } from "@app/hooks/useZipConfirmation";
|
||||
import ZipWarningModal from "@app/components/shared/ZipWarningModal";
|
||||
import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal";
|
||||
@@ -186,6 +187,21 @@ function FileContextInner({
|
||||
setUnlockError(null);
|
||||
}, [activeEncryptedFileId]);
|
||||
|
||||
// Storage proved a file's bytes unreadable (WebKit losing a blob's backing
|
||||
// store). Drop it: the viewer would otherwise spin on a document that can
|
||||
// never load. The record stays, so a reload re-tests it.
|
||||
useEffect(
|
||||
() =>
|
||||
onRecordUnreadable((fileId) => {
|
||||
if (!stateRef.current.files.byId[fileId]) return;
|
||||
console.error(
|
||||
`[FileContext] dropping ${fileId} from the workbench: its stored bytes are unreadable`,
|
||||
);
|
||||
lifecycleManager.removeFiles([fileId], stateRef);
|
||||
}),
|
||||
[lifecycleManager],
|
||||
);
|
||||
|
||||
const handleUnlockSkip = useCallback(() => {
|
||||
if (activeEncryptedFileId) {
|
||||
dismissedEncryptedFilesRef.current.add(activeEncryptedFileId);
|
||||
|
||||
@@ -455,7 +455,10 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
|
||||
})
|
||||
.map((s) => s.id);
|
||||
if (localIds.length > 0) {
|
||||
await fileActions.removeFiles(localIds, true);
|
||||
// Take the superseded versions with it, or their bytes sit in storage
|
||||
// forever - invisible, because listings only show leaves.
|
||||
const orphans = await fileStorage.orphanedAncestorIds(localIds);
|
||||
await fileActions.removeFiles([...localIds, ...orphans], true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -222,3 +222,40 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)",
|
||||
expect(next.ui.selectedFileIds).toEqual(["b2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileContextReducer — REMOVE_FILES", () => {
|
||||
/** Deleting from the library dispatches this for files that were never in the
|
||||
* workbench; reallocating then re-renders every consumer for nothing. */
|
||||
it("is a true no-op when none of the ids are in the workbench", () => {
|
||||
const state = stateWith([stub("a")]);
|
||||
const next = fileContextReducer(state, {
|
||||
type: "REMOVE_FILES",
|
||||
payload: { fileIds: ["gone" as FileId] },
|
||||
});
|
||||
expect(next).toBe(state);
|
||||
});
|
||||
|
||||
it("still removes the ids it does hold", () => {
|
||||
const state = stateWith([stub("a"), stub("b")]);
|
||||
const next = fileContextReducer(state, {
|
||||
type: "REMOVE_FILES",
|
||||
payload: { fileIds: ["a" as FileId, "gone" as FileId] },
|
||||
});
|
||||
expect(next.files.ids).toEqual(["b"]);
|
||||
expect(next.files.byId["a" as FileId]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the files slice when only a selection is cleared", () => {
|
||||
const base = stateWith([stub("a")]);
|
||||
const state: FileContextState = {
|
||||
...base,
|
||||
ui: { ...base.ui, selectedFileIds: ["gone" as FileId] },
|
||||
};
|
||||
const next = fileContextReducer(state, {
|
||||
type: "REMOVE_FILES",
|
||||
payload: { fileIds: ["gone" as FileId] },
|
||||
});
|
||||
expect(next.files).toBe(state.files);
|
||||
expect(next.ui.selectedFileIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,6 +183,20 @@ export function fileContextReducer(
|
||||
const remainingIds = state.files.ids.filter(
|
||||
(id) => !fileIds.includes(id),
|
||||
);
|
||||
// Clear selections that reference removed files
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(
|
||||
(id) => !fileIds.includes(id),
|
||||
);
|
||||
|
||||
// Deleting a library file that was never in the workbench removes nothing
|
||||
// here, and must not re-render every file and UI consumer.
|
||||
const removedFromWorkbench =
|
||||
remainingIds.length !== state.files.ids.length ||
|
||||
fileIds.some((id) => id in state.files.byId);
|
||||
const deselected =
|
||||
validSelectedFileIds.length !== state.ui.selectedFileIds.length;
|
||||
if (!removedFromWorkbench && !deselected) return state;
|
||||
|
||||
const newById = { ...state.files.byId };
|
||||
|
||||
// Remove files from state (resource cleanup handled by lifecycle manager)
|
||||
@@ -190,21 +204,14 @@ export function fileContextReducer(
|
||||
delete newById[id];
|
||||
});
|
||||
|
||||
// Clear selections that reference removed files
|
||||
const validSelectedFileIds = state.ui.selectedFileIds.filter(
|
||||
(id) => !fileIds.includes(id),
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
files: {
|
||||
ids: remainingIds,
|
||||
byId: newById,
|
||||
},
|
||||
ui: {
|
||||
...state.ui,
|
||||
selectedFileIds: validSelectedFileIds,
|
||||
},
|
||||
files: removedFromWorkbench
|
||||
? { ids: remainingIds, byId: newById }
|
||||
: state.files,
|
||||
ui: deselected
|
||||
? { ...state.ui, selectedFileIds: validSelectedFileIds }
|
||||
: state.ui,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
clearBulkAddProgress,
|
||||
} from "@app/services/bulkAddProgress";
|
||||
const DEBUG = process.env.NODE_ENV === "development";
|
||||
/** How long a file may sit unhydrated before the console says so. Reporting only:
|
||||
* the read is never abandoned, because large files legitimately take time. */
|
||||
const STALLED_LOAD_MS = 8000;
|
||||
const HYDRATION_CONCURRENCY = 2;
|
||||
let activeHydrations = 0;
|
||||
const hydrationQueue: Array<() => Promise<void>> = [];
|
||||
@@ -854,61 +857,78 @@ export async function addStirlingFileStubs(
|
||||
// Load File object and hydrate metadata in background (non-blocking)
|
||||
const fileId = stub.id;
|
||||
|
||||
// Load File object from IndexedDB asynchronously
|
||||
scheduleMetadataHydration(async () => {
|
||||
const stirlingFile = await fileStorage.getStirlingFile(fileId);
|
||||
// Regenerate page metadata + thumbnails. Queued, because parsing several
|
||||
// PDFs at once is what the concurrency limit exists to bound.
|
||||
const scheduleMetadataFor = (stirlingFile: StirlingFile): void => {
|
||||
scheduleMetadataHydration(async () => {
|
||||
const processedFileMetadata =
|
||||
await generateProcessedFileMetadata(stirlingFile);
|
||||
if (!processedFileMetadata) return;
|
||||
|
||||
const updates: Partial<StirlingFileStub> = {
|
||||
processedFile: processedFileMetadata,
|
||||
};
|
||||
|
||||
// Update thumbnail only if current stub doesn't have one
|
||||
const currentStub = stateRef.current.files.byId[fileId];
|
||||
if (
|
||||
!currentStub?.thumbnailUrl &&
|
||||
processedFileMetadata.thumbnailUrl
|
||||
) {
|
||||
updates.thumbnailUrl = processedFileMetadata.thumbnailUrl;
|
||||
if (processedFileMetadata.thumbnailUrl.startsWith("blob:")) {
|
||||
lifecycleManager.trackBlobUrl(processedFileMetadata.thumbnailUrl);
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef);
|
||||
});
|
||||
};
|
||||
|
||||
// Load and publish the File, ahead of any parsing. NOT queued: whether a
|
||||
// file opens at all must not wait on other files' parses.
|
||||
void (async () => {
|
||||
// A storage read that never settles renders as a file that silently won't
|
||||
// open. Name it in the console rather than leaving the user guessing.
|
||||
const stall = setTimeout(
|
||||
() =>
|
||||
console.error(
|
||||
`[Hydration] ${stub.name} (${fileId}) has been loading for ${STALLED_LOAD_MS / 1000}s - the IndexedDB read has not settled`,
|
||||
),
|
||||
STALLED_LOAD_MS,
|
||||
);
|
||||
const stirlingFile = await fileStorage
|
||||
.getStirlingFile(fileId)
|
||||
.finally(() => clearTimeout(stall));
|
||||
if (!stirlingFile) {
|
||||
// A row with no bytes renders empty and its clicks look dead, so take it
|
||||
// back out. Storage keeps the record; fileStorage has said why.
|
||||
console.error(
|
||||
`[Hydration] No readable data for ${stub.name} (${fileId}); removing it from the workbench`,
|
||||
);
|
||||
lifecycleManager.removeFiles([fileId], stateRef);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the loaded file in filesRef
|
||||
filesRef.current.set(fileId, stirlingFile);
|
||||
|
||||
// Check if processedFile data needs regeneration
|
||||
if (stirlingFile.type.startsWith("application/pdf")) {
|
||||
const needsProcessing =
|
||||
!stub.processedFile ||
|
||||
!stub.processedFile.pages ||
|
||||
stub.processedFile.pages.length === 0 ||
|
||||
stub.processedFile.totalPages !== stub.processedFile.pages.length;
|
||||
|
||||
if (needsProcessing) {
|
||||
// Regenerate metadata
|
||||
const processedFileMetadata =
|
||||
await generateProcessedFileMetadata(stirlingFile);
|
||||
|
||||
if (processedFileMetadata) {
|
||||
const updates: Partial<StirlingFileStub> = {
|
||||
processedFile: processedFileMetadata,
|
||||
};
|
||||
|
||||
// Update thumbnail only if current stub doesn't have one
|
||||
const currentStub = stateRef.current.files.byId[fileId];
|
||||
if (
|
||||
!currentStub?.thumbnailUrl &&
|
||||
processedFileMetadata.thumbnailUrl
|
||||
) {
|
||||
updates.thumbnailUrl = processedFileMetadata.thumbnailUrl;
|
||||
if (processedFileMetadata.thumbnailUrl.startsWith("blob:")) {
|
||||
lifecycleManager.trackBlobUrl(
|
||||
processedFileMetadata.thumbnailUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleManager.updateStirlingFileStub(
|
||||
fileId,
|
||||
updates,
|
||||
stateRef,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stub dispatch triggers re-render so the viewer appears (ADD_FILES alone doesn't update selectors).
|
||||
// filesRef is a ref, so the selectors gating the workbench only see the
|
||||
// file once something dispatches. Parsing it can't be a precondition.
|
||||
lifecycleManager.updateStirlingFileStub(fileId, {}, stateRef);
|
||||
});
|
||||
|
||||
const needsProcessing =
|
||||
!stub.processedFile ||
|
||||
!stub.processedFile.pages ||
|
||||
stub.processedFile.pages.length === 0 ||
|
||||
stub.processedFile.totalPages !== stub.processedFile.pages.length;
|
||||
if (
|
||||
stirlingFile.type.startsWith("application/pdf") &&
|
||||
needsProcessing
|
||||
) {
|
||||
scheduleMetadataFor(stirlingFile);
|
||||
}
|
||||
})().catch((error) =>
|
||||
console.error(`[Hydration] Failed to load ${fileId}:`, error),
|
||||
);
|
||||
}
|
||||
|
||||
return loadedFiles;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
FileContextState,
|
||||
StirlingFileStub,
|
||||
} from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
|
||||
/**
|
||||
* A clicked file is only visible once hydration DISPATCHES: the workbench reads
|
||||
* files out of a ref, so `activeFiles` stays empty until then. Parsing must not
|
||||
* gate that - a PDF engine that stalls used to leave the workbench on its empty
|
||||
* state with the row showing as open, and clicks doing nothing.
|
||||
*/
|
||||
|
||||
const getStirlingFile = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@app/services/fileStorage", () => ({
|
||||
fileStorage: { getStirlingFile },
|
||||
}));
|
||||
/** The stall under test: the page parse never settles. */
|
||||
vi.mock("@app/utils/thumbnailUtils", () => ({
|
||||
generateThumbnailPairWithMetadata: () => new Promise(() => {}),
|
||||
}));
|
||||
|
||||
const stub = (id: string): StirlingFileStub =>
|
||||
({
|
||||
id: id as FileId,
|
||||
name: `${id}.pdf`,
|
||||
type: "application/pdf",
|
||||
size: 10,
|
||||
lastModified: 0,
|
||||
}) as StirlingFileStub;
|
||||
|
||||
async function harness(ids: string[]) {
|
||||
vi.resetModules();
|
||||
getStirlingFile.mockImplementation(
|
||||
async (id: FileId) =>
|
||||
new File(["%PDF-1.7"], `${id}.pdf`, { type: "application/pdf" }),
|
||||
);
|
||||
const { addStirlingFileStubs } =
|
||||
await import("@app/contexts/file/fileActions");
|
||||
|
||||
const stubs = ids.map(stub);
|
||||
const state = {
|
||||
files: { ids: [], byId: {} },
|
||||
pinnedFiles: new Set(),
|
||||
ui: { selectedFileIds: [], selectedPageNumbers: [] },
|
||||
} as unknown as FileContextState;
|
||||
const stateRef = { current: state };
|
||||
const filesRef = { current: new Map<FileId, File>() };
|
||||
const published: FileId[] = [];
|
||||
const lifecycleManager = {
|
||||
updateStirlingFileStub: (fileId: FileId) => published.push(fileId),
|
||||
removeFiles: () => {},
|
||||
trackBlobUrl: () => {},
|
||||
};
|
||||
|
||||
await addStirlingFileStubs(
|
||||
stubs,
|
||||
{},
|
||||
stateRef,
|
||||
filesRef,
|
||||
() => {},
|
||||
lifecycleManager as never,
|
||||
);
|
||||
return { filesRef, published };
|
||||
}
|
||||
|
||||
describe("workbench hydration — a stalled parse can't hide the file", () => {
|
||||
test("publishes every file's bytes while their parses hang", async () => {
|
||||
// Three, because the parse queue only runs two at a time: the third proves
|
||||
// loading isn't queued behind parses that never finish.
|
||||
const { filesRef, published } = await harness(["a", "b", "c"]);
|
||||
|
||||
await vi.waitFor(() => expect(published).toHaveLength(3));
|
||||
expect([...filesRef.current.keys()]).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
@@ -389,6 +389,18 @@ export const useFileManager = () => {
|
||||
// Optimistic update — remove from UI immediately, delete IDB in background
|
||||
setFiles(files.filter((_, i) => i !== index));
|
||||
onRemovedFromWorkbench?.(file.id);
|
||||
// Superseded versions go with it (see orphanedAncestorIds); best-effort,
|
||||
// because failing to tidy history must not fail the delete itself.
|
||||
void fileStorage
|
||||
.orphanedAncestorIds([file.id])
|
||||
.then((orphans) =>
|
||||
orphans.length > 0
|
||||
? fileStorage.deleteMultipleStirlingFiles(orphans)
|
||||
: undefined,
|
||||
)
|
||||
.catch((error) =>
|
||||
console.warn("Failed to remove superseded versions:", error),
|
||||
);
|
||||
indexedDB.deleteFile(file.id).catch((error) => {
|
||||
console.error("Failed to remove file from IndexedDB:", error);
|
||||
// Restore consistency — file is still in IDB so refresh brings it back
|
||||
|
||||
@@ -3,23 +3,26 @@ import "fake-indexeddb/auto";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
/**
|
||||
* Regression test for the WebKit nightly breakage introduced with the
|
||||
* large-file OOM fix (#7175): `storeStirlingFile` began putting the `File`
|
||||
* itself into IndexedDB (persisted by reference, so multi-GB uploads never
|
||||
* materialize in JS memory). WebKit refuses blob values whenever it can't write
|
||||
* the blob's backing file and rejects the request with `UnknownError: Error
|
||||
* preparing Blob/File data to be stored in object store`, so on WebKit every
|
||||
* upload silently failed to persist: files vanished on navigation, Compare
|
||||
* slots never filled, and the classification backfill had no bytes to read.
|
||||
* WebKit refuses blob values when it can't write the blob's backing file, so
|
||||
* every upload silently failed to persist after #7175. Retried as a copy now.
|
||||
*
|
||||
* The service now retries such a rejection with an ArrayBuffer copy and stops
|
||||
* offering blobs for the rest of the session.
|
||||
* It can also accept one and then lose the backing store. fake-indexeddb returns no
|
||||
* Blob, so that loss is injected at the read; real round-trips: the e2e spec.
|
||||
*/
|
||||
|
||||
const nativeAdd = IDBObjectStore.prototype.add;
|
||||
const alertMock = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@app/components/toast", () => ({
|
||||
alert: (options: unknown) => alertMock(options),
|
||||
}));
|
||||
|
||||
/** What each `add` attempt carried in `data` — the blob path or the copy path. */
|
||||
const nativeAdd = IDBObjectStore.prototype.add;
|
||||
const nativePut = IDBObjectStore.prototype.put;
|
||||
const nativeGet = IDBObjectStore.prototype.get;
|
||||
|
||||
/** What each `add` attempt carried in `data`: blob path or copy path. */
|
||||
let attempts: Array<"blob" | "copy"> = [];
|
||||
/** The same, for `put` - the rewrite path a lost backing store recovers through. */
|
||||
let putAttempts: Array<"blob" | "copy"> = [];
|
||||
|
||||
/** An IDBRequest that fails asynchronously, the way WebKit rejects blob puts. */
|
||||
class FailingRequest extends EventTarget {
|
||||
@@ -32,10 +35,7 @@ class FailingRequest extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record every add attempt, optionally failing the blob-valued ones the way an
|
||||
* engine without blob storage does.
|
||||
*/
|
||||
/** Record every add, optionally failing the blob-valued ones. */
|
||||
function instrumentAdd(options: { rejectBlobs: boolean }) {
|
||||
IDBObjectStore.prototype.add = function (
|
||||
this: IDBObjectStore,
|
||||
@@ -58,11 +58,72 @@ function instrumentAdd(options: { rejectBlobs: boolean }) {
|
||||
} as typeof IDBObjectStore.prototype.add;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh service per test: whether the engine accepts blobs is remembered for
|
||||
* the process lifetime by design, so tests must not inherit that decision from
|
||||
* each other.
|
||||
*/
|
||||
/** Record every put, so the copy-rewrite recovery can be observed. */
|
||||
function instrumentPut() {
|
||||
IDBObjectStore.prototype.put = function (
|
||||
this: IDBObjectStore,
|
||||
value: unknown,
|
||||
key?: IDBValidKey,
|
||||
) {
|
||||
putAttempts.push(
|
||||
(value as { data?: unknown } | null)?.data instanceof Blob
|
||||
? "blob"
|
||||
: "copy",
|
||||
);
|
||||
return key === undefined
|
||||
? nativePut.call(this, value)
|
||||
: nativePut.call(this, value, key);
|
||||
} as typeof IDBObjectStore.prototype.put;
|
||||
}
|
||||
|
||||
/** A stored blob whose backing store the engine has lost: it still reports a name,
|
||||
* type and size, and every read of its bytes fails the way WebKit's does. */
|
||||
function blobWithLostBackingStore(): Blob {
|
||||
const lost = () => {
|
||||
throw new DOMException(
|
||||
"The object can not be found here.",
|
||||
"NotFoundError",
|
||||
);
|
||||
};
|
||||
return Object.assign(
|
||||
new Blob(["%PDF-1.7 stirling"], { type: "application/pdf" }),
|
||||
{ slice: lost, arrayBuffer: lost, text: lost, stream: lost },
|
||||
);
|
||||
}
|
||||
|
||||
/** The next `deadReads` reads come back with a lost backing store, later ones
|
||||
* untouched - so a repaired record can still be read normally. */
|
||||
function loseBackingStoreOnRead(deadReads: number) {
|
||||
let remaining = deadReads;
|
||||
IDBObjectStore.prototype.get = function (
|
||||
this: IDBObjectStore,
|
||||
key: IDBValidKey | IDBKeyRange,
|
||||
) {
|
||||
const request = nativeGet.call(this, key as IDBValidKey);
|
||||
// One substitution per request, however often `result` is read.
|
||||
let injected = false;
|
||||
return new Proxy(request, {
|
||||
get(target, prop) {
|
||||
// Receiver must be the real request: IDBRequest's accessors are branded.
|
||||
const value = Reflect.get(target, prop, target);
|
||||
if (prop !== "result") {
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
}
|
||||
if (!value || injected || remaining === 0) return value;
|
||||
injected = true;
|
||||
remaining--;
|
||||
return { ...(value as object), data: blobWithLostBackingStore() };
|
||||
},
|
||||
set(target, prop, value) {
|
||||
Reflect.set(target, prop, value, target);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
} as typeof IDBObjectStore.prototype.get;
|
||||
}
|
||||
|
||||
/** A fresh service per test: the blob decision is remembered by design, so
|
||||
* tests must not inherit it from each other. */
|
||||
async function freshFileStorage() {
|
||||
vi.resetModules();
|
||||
const [{ fileStorage }, { createStirlingFile, createNewStirlingFileStub }] =
|
||||
@@ -86,10 +147,58 @@ async function freshFileStorage() {
|
||||
|
||||
beforeEach(() => {
|
||||
attempts = [];
|
||||
putAttempts = [];
|
||||
alertMock.mockClear();
|
||||
// The blob verdict is deliberately durable, so each test must start undecided.
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
IDBObjectStore.prototype.add = nativeAdd;
|
||||
IDBObjectStore.prototype.put = nativePut;
|
||||
IDBObjectStore.prototype.get = nativeGet;
|
||||
});
|
||||
|
||||
/** Abort the transaction the moment a write is issued over it. */
|
||||
function abortOnPut() {
|
||||
IDBObjectStore.prototype.put = function (this: IDBObjectStore) {
|
||||
const request = new FailingRequest(
|
||||
new DOMException("transaction aborted", "AbortError"),
|
||||
) as unknown as IDBRequest<IDBValidKey>;
|
||||
this.transaction.abort();
|
||||
return request;
|
||||
} as typeof IDBObjectStore.prototype.put;
|
||||
}
|
||||
|
||||
describe("read-modify-write — a refused rewrite must not hang or vanish", () => {
|
||||
/** The abort guard used to sit on the read promise, leaving the write with a
|
||||
* dead reject - and `.catch` can't rescue a promise that never settles. */
|
||||
test("settles instead of hanging when the write transaction aborts", async () => {
|
||||
expectConsole.error(/Failed to mark file as processed/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await store("aborts.pdf");
|
||||
|
||||
abortOnPut();
|
||||
|
||||
// Before the fix this never settled and the test timed out.
|
||||
await expect(fileStorage.markFileAsProcessed(id)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
/** The copy-and-retry recovery can't be exercised here: it needs a record that
|
||||
* reads back as a Blob, which fake-indexeddb never returns. */
|
||||
test("a metadata rewrite still commits, and reports commit not put", async () => {
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await store("rewrite.pdf");
|
||||
|
||||
await expect(fileStorage.markFileAsProcessed(id)).resolves.toBe(true);
|
||||
// Missing record: `false`, not a throw and not a claim of success.
|
||||
await expect(
|
||||
fileStorage.markFileAsProcessed("nope" as never),
|
||||
).resolves.toBe(false);
|
||||
expect((await fileStorage.getStirlingFile(id))?.name).toBe("rewrite.pdf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("storeStirlingFile — blob-value fallback", () => {
|
||||
@@ -113,8 +222,7 @@ describe("storeStirlingFile — blob-value fallback", () => {
|
||||
const id = await store("webkit.pdf");
|
||||
|
||||
expect(attempts).toEqual(["blob", "copy"]);
|
||||
// Readable back is what every downstream consumer depends on: rehydration
|
||||
// after navigation, thumbnails, the classification backfill.
|
||||
// Readable back is what rehydration, thumbnails and backfill depend on.
|
||||
expect((await fileStorage.getStirlingFile(id))?.name).toBe("webkit.pdf");
|
||||
});
|
||||
|
||||
@@ -127,12 +235,32 @@ describe("storeStirlingFile — blob-value fallback", () => {
|
||||
attempts = [];
|
||||
const id = await store("second.pdf");
|
||||
|
||||
// Straight to the copy path — no repeated blob probe, and only the single
|
||||
// warning expected above.
|
||||
// Straight to the copy path, and only the one warning expected above.
|
||||
expect(attempts).toEqual(["copy"]);
|
||||
expect((await fileStorage.getStirlingFile(id))?.name).toBe("second.pdf");
|
||||
});
|
||||
|
||||
/** Committing is not evidence the bytes survived, and by the next reload the
|
||||
* source File is gone: without this the upload looks fine and the file is dead. */
|
||||
test("repairs a record whose stored blob loses its backing store", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
instrumentPut();
|
||||
loseBackingStoreOnRead(1); // only the store's own read-back is dead
|
||||
|
||||
const id = await store("dead-on-arrival.pdf");
|
||||
|
||||
// Accepted as a blob, then rewritten from the file still in hand.
|
||||
expect(attempts).toEqual(["blob"]);
|
||||
expect(putAttempts).toEqual(["copy"]);
|
||||
expect((await fileStorage.getStirlingFile(id))?.name).toBe(
|
||||
"dead-on-arrival.pdf",
|
||||
);
|
||||
// Self-healed, so nothing to tell the user about.
|
||||
expect(alertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not retry a failure a copy can't fix (quota)", async () => {
|
||||
const { store } = await freshFileStorage();
|
||||
IDBObjectStore.prototype.add = function (this: IDBObjectStore) {
|
||||
@@ -144,3 +272,246 @@ describe("storeStirlingFile — blob-value fallback", () => {
|
||||
expect(attempts).toEqual(["blob"]);
|
||||
});
|
||||
});
|
||||
|
||||
/** An earlier session's record can't be repaired, and the user has to be told - but
|
||||
* the telling must never gate the open. Awaiting the probe stalled every file in
|
||||
* Safari, where the probe read of a lost backing store never settles. */
|
||||
describe("reads — a stored blob whose bytes are gone", () => {
|
||||
test("hands the file over and reports the loss out of band", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
expectConsole.error(/cannot be read/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await store("lost.pdf");
|
||||
|
||||
loseBackingStoreOnRead(5); // every read from here on
|
||||
|
||||
// Not null, and not awaited on the probe: the caller is never blocked.
|
||||
expect((await fileStorage.getStirlingFile(id))?.name).toBe("lost.pdf");
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// Told once, not once per reader: every consumer of the file hits this record.
|
||||
expect(alertMock).toHaveBeenCalledTimes(1);
|
||||
expect(alertMock.mock.calls[0][0]).toMatchObject({
|
||||
alertType: "warning",
|
||||
body: expect.stringContaining("lost.pdf"),
|
||||
});
|
||||
});
|
||||
|
||||
/** The loop this closes: a reload re-decides optimistically, writes blobs the
|
||||
* engine loses again, and the browser never settles on a shape that works. */
|
||||
test("remembers across reloads that this browser loses blob values", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
expectConsole.error(/cannot be read/);
|
||||
const first = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await first.store("lost.pdf");
|
||||
loseBackingStoreOnRead(1);
|
||||
expect(await first.fileStorage.getStirlingFile(id)).not.toBeNull();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// A new page load: a fresh service, same browser profile.
|
||||
const next = await freshFileStorage();
|
||||
attempts = [];
|
||||
const later = await next.store("after-reload.pdf");
|
||||
|
||||
expect(attempts).toEqual(["copy"]);
|
||||
expect((await next.fileStorage.getStirlingFile(later))?.name).toBe(
|
||||
"after-reload.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
test("stops offering blob values for the rest of the session", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
expectConsole.error(/cannot be read/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const first = await store("lost.pdf");
|
||||
|
||||
loseBackingStoreOnRead(1);
|
||||
expect(await fileStorage.getStirlingFile(first)).not.toBeNull();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// An engine that loses a blob it accepted can't be trusted with the next one,
|
||||
// so the read failure degrades writes too.
|
||||
attempts = [];
|
||||
const second = await store("later.pdf");
|
||||
expect(attempts).toEqual(["copy"]);
|
||||
expect((await fileStorage.getStirlingFile(second))?.name).toBe("later.pdf");
|
||||
});
|
||||
});
|
||||
|
||||
/** Deleting a file used to leave its superseded versions in storage forever,
|
||||
* invisible (listings filter on isLeaf) and still holding their full bytes. */
|
||||
describe("orphanedAncestorIds", () => {
|
||||
const store = async (
|
||||
fileStorage: { storeStirlingFile: (f: never, s: never) => Promise<void> },
|
||||
id: string,
|
||||
parentFileId: string | undefined,
|
||||
isLeaf: boolean,
|
||||
) => {
|
||||
const { createStirlingFile, createNewStirlingFileStub } =
|
||||
await import("@app/types/fileContext");
|
||||
const file = new File(["%PDF-1.7"], `${id}.pdf`, {
|
||||
type: "application/pdf",
|
||||
});
|
||||
const base = createNewStirlingFileStub(file);
|
||||
await fileStorage.storeStirlingFile(
|
||||
createStirlingFile(file, id as never) as never,
|
||||
{ ...base, id, isLeaf, parentFileId, originalFileId: "v1" } as never,
|
||||
);
|
||||
};
|
||||
|
||||
test("takes the superseded versions with the leaf", async () => {
|
||||
const { fileStorage } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
await store(fileStorage as never, "v1", undefined, false);
|
||||
await store(fileStorage as never, "v2", "v1", true);
|
||||
|
||||
expect(await fileStorage.orphanedAncestorIds(["v2" as never])).toEqual([
|
||||
"v1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("leaves a split sibling's history alone", async () => {
|
||||
const { fileStorage } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
// Distinct ids: the fake database outlives the module reset between tests.
|
||||
await store(fileStorage as never, "split-root", undefined, false);
|
||||
await store(fileStorage as never, "split-a", "split-root", true);
|
||||
await store(fileStorage as never, "split-b", "split-root", true);
|
||||
|
||||
// `split-b` still descends from the root, so deleting `split-a` can't strip it.
|
||||
expect(await fileStorage.orphanedAncestorIds(["split-a" as never])).toEqual(
|
||||
[],
|
||||
);
|
||||
// Once both leaves go, the shared ancestor is genuinely unreachable.
|
||||
expect(
|
||||
await fileStorage.orphanedAncestorIds([
|
||||
"split-a" as never,
|
||||
"split-b" as never,
|
||||
]),
|
||||
).toEqual(["split-root"]);
|
||||
});
|
||||
});
|
||||
|
||||
/** Handing dead bytes over is only safe if whoever holds them is told to let go -
|
||||
* otherwise the viewer renders a document that never loads (an endless spinner). */
|
||||
describe("confirmed-unreadable records", () => {
|
||||
test("notifies listeners and refuses to hand the same file out twice", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
expectConsole.error(/cannot be read/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
const { onRecordUnreadable } = await import("@app/services/fileStorage");
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await store("doomed.pdf");
|
||||
|
||||
const dropped: string[] = [];
|
||||
const unsubscribe = onRecordUnreadable((fileId) => dropped.push(fileId));
|
||||
|
||||
loseBackingStoreOnRead(5);
|
||||
// First read still hands the file over: the probe is out of band.
|
||||
expect(await fileStorage.getStirlingFile(id)).not.toBeNull();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
// The holder is told, so the workbench can drop it instead of spinning.
|
||||
expect(dropped).toEqual([id]);
|
||||
// And a second consumer never gets the same dead bytes.
|
||||
expect(await fileStorage.getStirlingFile(id)).toBeNull();
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
/** A readable-blob substitute, for the rescue path: fake-indexeddb never returns
|
||||
* Blob values, so a healthy legacy blob record is injected the same way a dead
|
||||
* one is. */
|
||||
function substituteHealthyBlobOnRead(reads: number) {
|
||||
let remaining = reads;
|
||||
IDBObjectStore.prototype.get = function (
|
||||
this: IDBObjectStore,
|
||||
key: IDBValidKey | IDBKeyRange,
|
||||
) {
|
||||
const request = nativeGet.call(this, key as IDBValidKey);
|
||||
let injected = false;
|
||||
return new Proxy(request, {
|
||||
get(target, prop) {
|
||||
const value = Reflect.get(target, prop, target);
|
||||
if (prop !== "result") {
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
}
|
||||
if (!value || injected || remaining === 0) return value;
|
||||
injected = true;
|
||||
remaining--;
|
||||
return {
|
||||
...(value as object),
|
||||
data: new Blob(["%PDF-1.7 stirling"], { type: "application/pdf" }),
|
||||
};
|
||||
},
|
||||
set(target, prop, value) {
|
||||
Reflect.set(target, prop, value, target);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
} as typeof IDBObjectStore.prototype.get;
|
||||
}
|
||||
|
||||
/** The library must tell the truth per row: a record whose bytes are gone lists
|
||||
* as data-lost instead of a file that pretends to open. */
|
||||
describe("stub listings — data-lost auditing", () => {
|
||||
test("flags a dead record on the stub once the audit lands", async () => {
|
||||
expectConsole.warn(/could not read its bytes back/);
|
||||
expectConsole.error(/cannot be read/);
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
const id = await store("husk.pdf");
|
||||
|
||||
loseBackingStoreOnRead(1);
|
||||
// First read schedules the out-of-band audit; unknown is not yet flagged.
|
||||
expect(
|
||||
(await fileStorage.getStirlingFileStub(id))?.dataUnavailable,
|
||||
).toBeUndefined();
|
||||
await new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
expect((await fileStorage.getStirlingFileStub(id))?.dataUnavailable).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("rescues a still-readable legacy blob to a copy on a no-blob browser", async () => {
|
||||
// The durable verdict says this browser loses blob values...
|
||||
localStorage.setItem("stirling.indexeddb.blobValuesUnsupported", "true");
|
||||
const { fileStorage, store } = await freshFileStorage();
|
||||
instrumentAdd({ rejectBlobs: false });
|
||||
instrumentPut();
|
||||
const id = await store("legacy.pdf");
|
||||
|
||||
// ...and a legacy record still holds a READABLE blob: save it while we can.
|
||||
substituteHealthyBlobOnRead(5);
|
||||
await fileStorage.getStirlingFileStub(id);
|
||||
await vi.waitFor(() => expect(putAttempts).toContain("copy"));
|
||||
// Rescued, not condemned: the stub stays openable.
|
||||
expect(
|
||||
(await fileStorage.getStirlingFileStub(id))?.dataUnavailable,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/** One hung request inside the TTL bump's readwrite transaction wedged the whole
|
||||
* store: every later read and write queued behind it forever - the infinite
|
||||
* "Loading files..." after a Safari reload. Maintenance must not touch
|
||||
* blob-bodied records on a browser that can't rewrite them anyway. */
|
||||
describe("maintenanceMayRewrite", () => {
|
||||
test("keeps maintenance away from blob records on a no-blob browser", async () => {
|
||||
const { maintenanceMayRewrite } = await import("@app/services/fileStorage");
|
||||
const blobRecord = { data: new Blob(["x"]) };
|
||||
const copyRecord = { data: new ArrayBuffer(1) };
|
||||
|
||||
expect(maintenanceMayRewrite(blobRecord, false)).toBe(false);
|
||||
// Copies never hang and their rewrite is accepted - always safe.
|
||||
expect(maintenanceMayRewrite(copyRecord, false)).toBe(true);
|
||||
// On engines that genuinely support blobs (Chrome), nothing changes.
|
||||
expect(maintenanceMayRewrite(blobRecord, true)).toBe(true);
|
||||
expect(maintenanceMayRewrite(copyRecord, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
indexedDBManager,
|
||||
DATABASE_CONFIGS,
|
||||
} from "@app/services/indexedDBManager";
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
/**
|
||||
* Storage record - single source of truth
|
||||
@@ -75,15 +76,135 @@ function isBlobValueRejection(error: unknown): boolean {
|
||||
return name === "UnknownError" || name === "DataCloneError";
|
||||
}
|
||||
|
||||
/** This engine loses Blob values, remembered per browser: session-scoped, each
|
||||
* reload re-decides optimistically and writes more files it will lose. */
|
||||
const BLOB_VALUES_UNSUPPORTED_KEY = "stirling.indexeddb.blobValuesUnsupported";
|
||||
|
||||
function readBlobValuesSupported(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(BLOB_VALUES_UNSUPPORTED_KEY) !== "true";
|
||||
} catch {
|
||||
// Storage unavailable (private mode): decide fresh each session.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function persistBlobValuesUnsupported(): void {
|
||||
try {
|
||||
localStorage.setItem(BLOB_VALUES_UNSUPPORTED_KEY, "true");
|
||||
} catch {
|
||||
// Storage unavailable: the session-scoped flag still degrades this session.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether maintenance writes (the thumbnail TTL bump) may re-read/re-write this
|
||||
* record. In WebKit, a `get` touching a blob-bodied record whose backing store is
|
||||
* damaged can HANG rather than error - and one pending request wedges the whole
|
||||
* object store: every later transaction, read or write, queues behind it forever.
|
||||
* That was the infinite "Loading files..." after a reload: the TTL bump's
|
||||
* transaction never completed, so nothing else on the store ever ran. On a
|
||||
* browser whose verdict is "blobs unsupported" the rewrite would be refused
|
||||
* anyway, so blob-bodied records are not worth the risk of touching at all.
|
||||
*/
|
||||
export function maintenanceMayRewrite(
|
||||
record: { data: ArrayBuffer | Blob },
|
||||
blobValuesSupported: boolean,
|
||||
): boolean {
|
||||
return !(record.data instanceof Blob) || blobValuesSupported;
|
||||
}
|
||||
|
||||
/** WebKit loses backing stores for blobs it accepted, and only a real read shows
|
||||
* it. One byte is enough: what fails is opening the store, not the length. */
|
||||
async function blobReadFailure(data: Blob): Promise<unknown> {
|
||||
try {
|
||||
await data.slice(0, 1).arrayBuffer();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error ?? new Error("Reading a stored blob's bytes failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Notified when a record's bytes are proven unreadable, so whoever is holding the
|
||||
* file can drop it instead of rendering a document that never arrives. */
|
||||
const unreadableListeners = new Set<(fileId: FileId) => void>();
|
||||
|
||||
export function onRecordUnreadable(
|
||||
listener: (fileId: FileId) => void,
|
||||
): () => void {
|
||||
unreadableListeners.add(listener);
|
||||
return () => unreadableListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** The probe read itself can hang in WebKit, so anything that awaits it needs a
|
||||
* deadline. Distinct from a failure: nothing was proven either way. */
|
||||
const PROBE_UNANSWERED = { unanswered: true } as const;
|
||||
const PROBE_DEADLINE_MS = 3000;
|
||||
|
||||
function withProbeDeadline(
|
||||
probe: Promise<unknown>,
|
||||
): Promise<unknown | typeof PROBE_UNANSWERED> {
|
||||
return Promise.race([
|
||||
probe,
|
||||
new Promise<typeof PROBE_UNANSWERED>((resolve) =>
|
||||
setTimeout(() => resolve(PROBE_UNANSWERED), PROBE_DEADLINE_MS),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The File for a stored record. Re-wrapping a stored blob can cost WebKit the
|
||||
* backing handle, so hand it back untouched when its identity fields match.
|
||||
*/
|
||||
function fileFromRecord(record: StoredStirlingFileRecord): File {
|
||||
const { data } = record;
|
||||
if (
|
||||
data instanceof File &&
|
||||
data.name === record.name &&
|
||||
data.type === record.type &&
|
||||
data.lastModified === record.lastModified
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
return new File([data], record.name, {
|
||||
type: record.type,
|
||||
lastModified: record.lastModified,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle on abort, for promises whose settle paths (a cursor tick, a request not
|
||||
* yet issued) never arrive. Call ONCE per transaction - there is one slot.
|
||||
*/
|
||||
function settleOnAbort(
|
||||
transaction: IDBTransaction,
|
||||
settle: (reason: Error) => void,
|
||||
): void {
|
||||
transaction.onabort = () =>
|
||||
settle(
|
||||
transaction.error ??
|
||||
new Error("IndexedDB transaction aborted before it completed"),
|
||||
);
|
||||
}
|
||||
|
||||
class FileStorageService {
|
||||
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||
private readonly storeName = "files";
|
||||
/**
|
||||
* Whether this engine accepts Blob/File values in IndexedDB. Optimistic: the
|
||||
* blob path avoids copying multi-GB files into JS memory, so we try it and
|
||||
* remember the answer, rather than pre-emptively degrading everywhere.
|
||||
*/
|
||||
private blobValuesSupported = true;
|
||||
/** Whether this engine takes Blob/File values, which avoid copying multi-GB
|
||||
* files into JS memory. Optimistic; a No outlives the session (see the key). */
|
||||
private blobValuesSupported = readBlobValuesSupported();
|
||||
/** Whether a stored blob's bytes have come back yet. Until they have, each
|
||||
* store proves it: accepting the write is no evidence the bytes survived. */
|
||||
private blobReadbackVerified = false;
|
||||
/** Ids whose TTL write failed. Without this the swallowed failure repeats a
|
||||
* whole-file rewrite on every listing. Session-scoped on purpose. */
|
||||
private readonly unwritableRecords = new Set<FileId>();
|
||||
/** Ids already reported as unreadable, so one dead record is surfaced once
|
||||
* rather than on every read of it. Session-scoped on purpose. */
|
||||
private readonly unreadableRecords = new Set<FileId>();
|
||||
/** Ids whose blob bytes this session has already audited (either way), so
|
||||
* listings don't re-probe every record on every refresh. */
|
||||
private readonly auditedRecords = new Set<FileId>();
|
||||
|
||||
/**
|
||||
* Get database connection using centralized manager
|
||||
@@ -101,7 +222,8 @@ class FileStorageService {
|
||||
|
||||
/** Fire-and-forget: bump thumbnailStoredAt (or clear expired thumbnail) for a set of ids. */
|
||||
private async bumpThumbnailTTL(ids: FileId[], clear = false): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const targets = ids.filter((id) => !this.unwritableRecords.has(id));
|
||||
if (targets.length === 0) return;
|
||||
const db = await this.getDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
@@ -112,7 +234,7 @@ class FileStorageService {
|
||||
|
||||
// Issue all gets up front - each onsuccess creates a put before the
|
||||
// transaction can auto-commit, keeping it alive until all puts settle.
|
||||
ids.forEach((id) => {
|
||||
targets.forEach((id) => {
|
||||
const req = store.get(id);
|
||||
req.onsuccess = () => {
|
||||
const record = req.result as StoredStirlingFileRecord | undefined;
|
||||
@@ -123,7 +245,30 @@ class FileStorageService {
|
||||
} else {
|
||||
record.thumbnailStoredAt = Date.now();
|
||||
}
|
||||
store.put(record);
|
||||
// One unwritable record must not take the batch with it: a rejected
|
||||
// put aborts the transaction the other queued gets are still using.
|
||||
try {
|
||||
const put = store.put(record);
|
||||
put.onerror = (event) => {
|
||||
// The write we just swallowed is the one that would have taken
|
||||
// this record out of the expiring set, so stop retrying it.
|
||||
this.unwritableRecords.add(id);
|
||||
this.noteBlobRefusal(put.error);
|
||||
console.warn(
|
||||
`[fileStorage] thumbnail TTL bump skipped for ${id}:`,
|
||||
put.error,
|
||||
);
|
||||
// Swallow it here so the failure doesn't abort the transaction.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
} catch (error) {
|
||||
this.unwritableRecords.add(id);
|
||||
console.warn(
|
||||
`[fileStorage] thumbnail TTL bump could not be issued for ${id}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
@@ -186,18 +331,255 @@ class FileStorageService {
|
||||
} catch (error) {
|
||||
// Recoverable: re-add as a copy, and stop offering blobs this session.
|
||||
// Anything else is the caller's to report.
|
||||
if (!(record.data instanceof Blob) || !isBlobValueRejection(error)) {
|
||||
if (!(record.data instanceof Blob) || !this.noteBlobRefusal(error)) {
|
||||
throw error;
|
||||
}
|
||||
this.blobValuesSupported = false;
|
||||
console.warn(
|
||||
"IndexedDB rejected a Blob value; falling back to in-memory copies for this session. " +
|
||||
"Very large files may now exhaust renderer memory.",
|
||||
error,
|
||||
);
|
||||
record.data = await record.data.arrayBuffer();
|
||||
await this.addFileRecord(db, record);
|
||||
return;
|
||||
}
|
||||
|
||||
// Committed is not retrievable. Prove the round-trip while the source File is
|
||||
// still in hand; after a reload there is nothing left to repair from.
|
||||
if (record.data instanceof Blob && !this.blobReadbackVerified) {
|
||||
await this.verifyStoredBlobReadable(db, record, stirlingFile);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one stored blob back, rewriting the record from {@code source} if its
|
||||
* bytes don't come with it. Runs until one round-trip succeeds. */
|
||||
private async verifyStoredBlobReadable(
|
||||
db: IDBDatabase,
|
||||
record: StoredStirlingFileRecord,
|
||||
source: File,
|
||||
): Promise<void> {
|
||||
// A record we can't read back at all is the caller's problem, not the probe's.
|
||||
const stored = await this.readRecord(db, record.id).catch(() => undefined);
|
||||
if (!(stored?.data instanceof Blob)) return;
|
||||
|
||||
const failure = await withProbeDeadline(blobReadFailure(stored.data));
|
||||
if (!failure) {
|
||||
this.blobReadbackVerified = true;
|
||||
return;
|
||||
}
|
||||
if (failure === PROBE_UNANSWERED) {
|
||||
// Nothing proven, and an upload must never wait on a probe. Leave the record
|
||||
// as written; the read path reports it if the bytes really are gone.
|
||||
console.warn(
|
||||
`[fileStorage] readability probe for ${record.id} did not answer in ${PROBE_DEADLINE_MS}ms`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.noteBlobUnreadable(failure);
|
||||
try {
|
||||
record.data = await source.arrayBuffer();
|
||||
await this.putRecord(db, record);
|
||||
} catch (error) {
|
||||
// The record is unusable either way, and the read path reports that to the
|
||||
// user. Don't turn a write that already committed into a failure.
|
||||
console.warn(
|
||||
`[fileStorage] could not rewrite ${record.id} as an in-memory copy:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Refused a Blob value? Stop offering blobs on this browser. Any write can flip
|
||||
* this: WebKit refuses per-operation, not per-engine. */
|
||||
private noteBlobRefusal(error: unknown): boolean {
|
||||
if (!isBlobValueRejection(error)) return false;
|
||||
this.disableBlobValues(
|
||||
"IndexedDB rejected a Blob value; falling back to in-memory copies on this browser. " +
|
||||
"Very large files may now exhaust renderer memory.",
|
||||
error,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A stored blob whose bytes won't read back means blob values can't be trusted
|
||||
* on this engine either, even though it accepted the write. */
|
||||
private noteBlobUnreadable(error: unknown): void {
|
||||
this.disableBlobValues(
|
||||
"IndexedDB accepted a Blob value but could not read its bytes back; " +
|
||||
"falling back to in-memory copies on this browser. " +
|
||||
"Very large files may now exhaust renderer memory.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
private disableBlobValues(message: string, error: unknown): void {
|
||||
if (!this.blobValuesSupported) return;
|
||||
this.blobValuesSupported = false;
|
||||
persistBlobValuesUnsupported();
|
||||
console.warn(message, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit a blob-backed record's bytes WITHOUT gating anything on the answer.
|
||||
* Awaiting this was a mistake: in Safari the probe read of a lost backing store
|
||||
* can stay pending forever, so it stalled every file open instead of the one
|
||||
* consumer that would have failed anyway.
|
||||
*
|
||||
* Two outcomes, both out of band:
|
||||
* - Bytes gone: mark + report, so the library shows "data lost" instead of a
|
||||
* file that pretends to open.
|
||||
* - Bytes readable on a browser whose verdict is "blobs unsupported": RESCUE the
|
||||
* record to an ArrayBuffer copy now, while the bytes still exist. Legacy blob
|
||||
* records on WebKit are one engine hiccup away from being lost for good.
|
||||
*/
|
||||
private reportIfUnreadable(record: StoredStirlingFileRecord): void {
|
||||
if (!(record.data instanceof Blob)) return;
|
||||
if (this.auditedRecords.has(record.id)) return;
|
||||
this.auditedRecords.add(record.id);
|
||||
void blobReadFailure(record.data).then((failure) => {
|
||||
if (!failure) {
|
||||
this.blobReadbackVerified = true;
|
||||
if (!this.blobValuesSupported) void this.rescueBlobRecord(record.id);
|
||||
return;
|
||||
}
|
||||
this.noteBlobUnreadable(failure);
|
||||
this.reportUnreadableRecord(record, failure);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite one still-readable legacy blob record as an ArrayBuffer copy. Reads
|
||||
* the FULL bytes (the audit only proved the first one) and goes through
|
||||
* {@link updateRecord}'s read-modify-write so a concurrent metadata update
|
||||
* isn't clobbered by a stale snapshot.
|
||||
*/
|
||||
private async rescueBlobRecord(fileId: FileId): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const record = await this.readRecord(db, fileId);
|
||||
if (!(record?.data instanceof Blob)) return;
|
||||
const bytes = await withProbeDeadline(record.data.arrayBuffer());
|
||||
if (bytes === PROBE_UNANSWERED || !(bytes instanceof ArrayBuffer)) return;
|
||||
record.data = bytes;
|
||||
await this.putRecord(db, record);
|
||||
console.info(
|
||||
`[fileStorage] rescued "${record.name}" (${fileId}) to an in-memory copy before this browser could lose its blob`,
|
||||
);
|
||||
} catch (error) {
|
||||
// Best-effort: a failed rescue leaves the record exactly as it was.
|
||||
console.warn(`[fileStorage] could not rescue ${fileId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** One console error and one toast per dead record: every consumer of the file
|
||||
* hits the same record, and the user needs the reason once, not per reader. */
|
||||
private reportUnreadableRecord(
|
||||
record: StoredStirlingFileRecord,
|
||||
failure: unknown,
|
||||
): void {
|
||||
if (this.unreadableRecords.has(record.id)) return;
|
||||
this.unreadableRecords.add(record.id);
|
||||
// Whoever is holding it needs to let go, or the viewer renders a document
|
||||
// whose bytes never arrive - a spinner with no terminal state.
|
||||
for (const listener of unreadableListeners) listener(record.id);
|
||||
console.error(
|
||||
`[fileStorage] stored data for "${record.name}" (${record.id}) cannot be read; ` +
|
||||
"the browser no longer has the blob's backing store",
|
||||
failure,
|
||||
);
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: "File data is unavailable",
|
||||
body:
|
||||
`"${record.name}" is saved in this browser but its contents can no longer be read. ` +
|
||||
"Upload the file again to keep working on it.",
|
||||
expandable: false,
|
||||
durationMs: 8000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read-modify-write one record in one transaction, resolving on COMMIT. Split
|
||||
* across two promises, the abort guard covers one and the other hangs. */
|
||||
private async updateRecord(
|
||||
fileId: FileId,
|
||||
mutate: (record: StoredStirlingFileRecord) => boolean | void,
|
||||
): Promise<boolean> {
|
||||
const db = await this.getDatabase();
|
||||
try {
|
||||
return await this.readModifyWrite(db, fileId, mutate);
|
||||
} catch (error) {
|
||||
// The record we read back still carries its Blob body; retry as a copy.
|
||||
if (!this.noteBlobRefusal(error)) throw error;
|
||||
return await this.rewriteRecordAsCopy(db, fileId, mutate);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link updateRecord}'s happy path: one transaction, resolve on commit. */
|
||||
private readModifyWrite(
|
||||
db: IDBDatabase,
|
||||
fileId: FileId,
|
||||
mutate: (record: StoredStirlingFileRecord) => boolean | void,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
let written = false;
|
||||
settleOnAbort(transaction, reject);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.oncomplete = () => resolve(written);
|
||||
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const getRequest = store.get(fileId);
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
getRequest.onsuccess = () => {
|
||||
const record = getRequest.result as
|
||||
| StoredStirlingFileRecord
|
||||
| undefined;
|
||||
// Nothing to write: let the empty transaction commit and report false.
|
||||
if (!record || mutate(record) === false) return;
|
||||
written = true;
|
||||
store.put(record);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Recovery path: two transactions, because materializing the copy is async
|
||||
* and a transaction cannot survive an await. Last-write-wins either way. */
|
||||
private async rewriteRecordAsCopy(
|
||||
db: IDBDatabase,
|
||||
fileId: FileId,
|
||||
mutate: (record: StoredStirlingFileRecord) => boolean | void,
|
||||
): Promise<boolean> {
|
||||
const record = await this.readRecord(db, fileId);
|
||||
if (!record || mutate(record) === false) return false;
|
||||
if (record.data instanceof Blob) {
|
||||
record.data = await record.data.arrayBuffer();
|
||||
}
|
||||
await this.putRecord(db, record);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One record by id, in its own transaction. */
|
||||
private readRecord(
|
||||
db: IDBDatabase,
|
||||
fileId: FileId,
|
||||
): Promise<StoredStirlingFileRecord | undefined> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
settleOnAbort(transaction, reject);
|
||||
const request = transaction.objectStore(this.storeName).get(fileId);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
/** One `put`, resolving on commit. */
|
||||
private putRecord(
|
||||
db: IDBDatabase,
|
||||
record: StoredStirlingFileRecord,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
settleOnAbort(transaction, reject);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.objectStore(this.storeName).put(record);
|
||||
});
|
||||
}
|
||||
|
||||
/** Single `add` of a file record. Rejects with the underlying IDB error. */
|
||||
@@ -215,6 +597,7 @@ class FileStorageService {
|
||||
}
|
||||
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
settleOnAbort(transaction, reject);
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const request = store.add(record);
|
||||
@@ -227,37 +610,21 @@ class FileStorageService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get StirlingFile with full data - for loading into workbench
|
||||
*/
|
||||
/** Get StirlingFile with full data - for loading into workbench. Null covers
|
||||
* both no such record and bytes gone; neither is a file callers can use. */
|
||||
async getStirlingFile(id: FileId): Promise<StirlingFile | null> {
|
||||
// Already proven unreadable this session: don't hand the same dead bytes to
|
||||
// another consumer that will spin on them. Session-scoped, so a reload retries.
|
||||
if (this.unreadableRecords.has(id)) return null;
|
||||
const db = await this.getDatabase();
|
||||
const record = await this.readRecord(db, id);
|
||||
if (!record) return null;
|
||||
// Reporting only, and NEVER awaited: WebKit can leave a read of a lost backing
|
||||
// store pending forever, and this is the path every file open goes through.
|
||||
this.reportIfUnreadable(record);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as StoredStirlingFileRecord | undefined;
|
||||
if (!record) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create File from stored data
|
||||
const blob = new Blob([record.data], { type: record.type });
|
||||
const file = new File([blob], record.name, {
|
||||
type: record.type,
|
||||
lastModified: record.lastModified,
|
||||
});
|
||||
|
||||
// Convert to StirlingFile with preserved IDs
|
||||
const stirlingFile = createStirlingFile(file, record.fileId);
|
||||
resolve(stirlingFile);
|
||||
};
|
||||
});
|
||||
// Convert to StirlingFile with preserved IDs
|
||||
return createStirlingFile(fileFromRecord(record), record.fileId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,6 +645,7 @@ class FileStorageService {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
settleOnAbort(transaction, reject);
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
@@ -295,9 +663,13 @@ class FileStorageService {
|
||||
// We still gate thumbnailUrl on freshness so stale thumbnails
|
||||
// don't leak through this read path.
|
||||
const fresh = this.isThumbnailFresh(record);
|
||||
// Out-of-band byte audit, so the library reflects lost data (and rescues
|
||||
// still-readable legacy blobs) instead of listing files that can't open.
|
||||
this.reportIfUnreadable(record);
|
||||
|
||||
const stub: StirlingFileStub = {
|
||||
id: record.id,
|
||||
dataUnavailable: this.unreadableRecords.has(record.id) || undefined,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
size: record.size,
|
||||
@@ -338,6 +710,7 @@ class FileStorageService {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
settleOnAbort(transaction, reject);
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.openCursor();
|
||||
const stubs: StirlingFileStub[] = [];
|
||||
@@ -352,12 +725,18 @@ class FileStorageService {
|
||||
const record = cursor.value as StoredStirlingFileRecord;
|
||||
if (record && record.name && typeof record.size === "number") {
|
||||
const fresh = this.isThumbnailFresh(record);
|
||||
if (record.thumbnail) {
|
||||
if (
|
||||
record.thumbnail &&
|
||||
maintenanceMayRewrite(record, this.blobValuesSupported)
|
||||
) {
|
||||
if (fresh) tobump.push(record.id);
|
||||
else toexpire.push(record.id);
|
||||
}
|
||||
this.reportIfUnreadable(record);
|
||||
stubs.push({
|
||||
id: record.id,
|
||||
dataUnavailable:
|
||||
this.unreadableRecords.has(record.id) || undefined,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
size: record.size,
|
||||
@@ -425,6 +804,7 @@ class FileStorageService {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
settleOnAbort(transaction, reject);
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.openCursor();
|
||||
const leafStubs: StirlingFileStub[] = [];
|
||||
@@ -444,12 +824,18 @@ class FileStorageService {
|
||||
record.isLeaf !== false
|
||||
) {
|
||||
const fresh = this.isThumbnailFresh(record);
|
||||
if (record.thumbnail) {
|
||||
if (
|
||||
record.thumbnail &&
|
||||
maintenanceMayRewrite(record, this.blobValuesSupported)
|
||||
) {
|
||||
if (fresh) tobump.push(record.id);
|
||||
else toexpire.push(record.id);
|
||||
}
|
||||
this.reportIfUnreadable(record);
|
||||
leafStubs.push({
|
||||
id: record.id,
|
||||
dataUnavailable:
|
||||
this.unreadableRecords.has(record.id) || undefined,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
size: record.size,
|
||||
@@ -579,6 +965,46 @@ class FileStorageService {
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Superseded versions that nothing else needs once {@code deleting} goes.
|
||||
*
|
||||
* Deleting a file removes one record; its older versions keep their full bytes
|
||||
* and are invisible (listings filter on isLeaf), so they accumulate forever.
|
||||
* Only for user-facing "delete this file" - deleting ONE version from the
|
||||
* history journey must leave the rest of the chain alone.
|
||||
*/
|
||||
async orphanedAncestorIds(deleting: FileId[]): Promise<FileId[]> {
|
||||
if (deleting.length === 0) return [];
|
||||
const stubs = await this.getAllStirlingFileStubs();
|
||||
const byId = new Map(stubs.map((s) => [s.id as string, s]));
|
||||
const doomed = new Set(deleting.map(String));
|
||||
|
||||
// Anything a surviving record descends from has to stay: split siblings
|
||||
// share a lineage, so one leaf's delete must not strip another's history.
|
||||
const keep = new Set<string>();
|
||||
for (const stub of stubs) {
|
||||
if (doomed.has(stub.id as string)) continue;
|
||||
let cursor = stub.parentFileId as string | undefined;
|
||||
while (cursor && !keep.has(cursor)) {
|
||||
keep.add(cursor);
|
||||
cursor = byId.get(cursor)?.parentFileId as string | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const orphans: FileId[] = [];
|
||||
for (const id of deleting) {
|
||||
let cursor = byId.get(String(id))?.parentFileId as string | undefined;
|
||||
while (cursor) {
|
||||
if (!keep.has(cursor) && !doomed.has(cursor) && byId.has(cursor)) {
|
||||
doomed.add(cursor);
|
||||
orphans.push(cursor as FileId);
|
||||
}
|
||||
cursor = byId.get(cursor)?.parentFileId as string | undefined;
|
||||
}
|
||||
}
|
||||
return orphans;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete StirlingFile - single operation, no sync issues
|
||||
*/
|
||||
@@ -587,11 +1013,12 @@ class FileStorageService {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.delete(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
// On commit, not on the request: callers refresh their list from storage as
|
||||
// soon as this resolves, and an aborted delete would put the row back.
|
||||
settleOnAbort(transaction, reject);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.objectStore(this.storeName).delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -617,45 +1044,16 @@ class FileStorageService {
|
||||
* Update thumbnail for existing file
|
||||
*/
|
||||
async updateThumbnail(id: FileId, thumbnail: string): Promise<boolean> {
|
||||
const db = await this.getDatabase();
|
||||
|
||||
return new Promise((resolve, _reject) => {
|
||||
try {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const getRequest = store.get(id);
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
const record = getRequest.result as StoredStirlingFileRecord;
|
||||
if (record) {
|
||||
record.thumbnail = thumbnail;
|
||||
record.thumbnailStoredAt = Date.now();
|
||||
const updateRequest = store.put(record);
|
||||
|
||||
updateRequest.onsuccess = () => {
|
||||
resolve(true);
|
||||
};
|
||||
updateRequest.onerror = () => {
|
||||
console.error("Failed to update thumbnail:", updateRequest.error);
|
||||
resolve(false);
|
||||
};
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
getRequest.onerror = () => {
|
||||
console.error(
|
||||
"Failed to get file for thumbnail update:",
|
||||
getRequest.error,
|
||||
);
|
||||
resolve(false);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Transaction error during thumbnail update:", error);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
// Reports failure as `false` rather than rejecting; callers just need an answer.
|
||||
try {
|
||||
return await this.updateRecord(id, (record) => {
|
||||
record.thumbnail = thumbnail;
|
||||
record.thumbnailStoredAt = Date.now();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update thumbnail:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -666,6 +1064,7 @@ class FileStorageService {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
settleOnAbort(transaction, reject);
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.clear();
|
||||
|
||||
@@ -720,24 +1119,16 @@ class FileStorageService {
|
||||
async createBlobUrl(id: FileId): Promise<string | null> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const record = await this.readRecord(db, id);
|
||||
if (!record) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readonly");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
const request = store.get(id);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const record = request.result as StoredStirlingFileRecord | undefined;
|
||||
if (record) {
|
||||
const blob = new Blob([record.data], { type: record.type });
|
||||
const url = URL.createObjectURL(blob);
|
||||
resolve(url);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
});
|
||||
// Stored blobs are handed straight to createObjectURL — re-wrapping
|
||||
// one can cost WebKit the backing handle. See fileFromRecord.
|
||||
const blob =
|
||||
record.data instanceof Blob
|
||||
? record.data
|
||||
: new Blob([record.data], { type: record.type });
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to create blob URL for ${id}:`, error);
|
||||
return null;
|
||||
@@ -750,32 +1141,9 @@ class FileStorageService {
|
||||
*/
|
||||
async markFileAsProcessed(fileId: FileId): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const record = await new Promise<StoredStirlingFileRecord | undefined>(
|
||||
(resolve, reject) => {
|
||||
const request = store.get(fileId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
},
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
return false; // File not found
|
||||
}
|
||||
|
||||
// Update the isLeaf flag to false
|
||||
record.isLeaf = false;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(record);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
return await this.updateRecord(fileId, (record) => {
|
||||
record.isLeaf = false;
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to mark file as processed:", error);
|
||||
return false;
|
||||
@@ -835,32 +1203,9 @@ class FileStorageService {
|
||||
*/
|
||||
async markFileAsLeaf(fileId: FileId): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
|
||||
const record = await new Promise<StoredStirlingFileRecord | undefined>(
|
||||
(resolve, reject) => {
|
||||
const request = store.get(fileId);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
},
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
return false; // File not found
|
||||
}
|
||||
|
||||
// Update the isLeaf flag to true
|
||||
record.isLeaf = true;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = store.put(record);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
return await this.updateRecord(fileId, (record) => {
|
||||
record.isLeaf = true;
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to mark file as leaf:", error);
|
||||
return false;
|
||||
@@ -870,41 +1215,16 @@ class FileStorageService {
|
||||
/**
|
||||
* Update metadata fields for a stored file record.
|
||||
*
|
||||
* Resolves on transaction.oncomplete, NOT on the individual put's onsuccess,
|
||||
* so callers only receive `true` once the write actually commits. If the
|
||||
* transaction aborts after put() succeeded but before commit, we return false
|
||||
* - the previous behavior incorrectly claimed success in that window.
|
||||
* Returns `true` only once the write commits, never on the put's `onsuccess`.
|
||||
* {@link updateRecord} owns that guarantee for every write in this class.
|
||||
*/
|
||||
async updateFileMetadata(
|
||||
fileId: FileId,
|
||||
updates: Partial<StoredStirlingFileRecord>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const db = await this.getDatabase();
|
||||
return await new Promise<boolean>((resolve, reject) => {
|
||||
const transaction = db.transaction([this.storeName], "readwrite");
|
||||
const store = transaction.objectStore(this.storeName);
|
||||
let recordFound = false;
|
||||
|
||||
const getRequest = store.get(fileId);
|
||||
getRequest.onsuccess = () => {
|
||||
const record = getRequest.result as
|
||||
| StoredStirlingFileRecord
|
||||
| undefined;
|
||||
if (!record) {
|
||||
// Don't commit anything; caller wants false.
|
||||
return;
|
||||
}
|
||||
recordFound = true;
|
||||
const updatedRecord = { ...record, ...updates };
|
||||
store.put(updatedRecord);
|
||||
};
|
||||
getRequest.onerror = () => reject(getRequest.error);
|
||||
|
||||
transaction.oncomplete = () => resolve(recordFound);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("updateFileMetadata aborted"));
|
||||
return await this.updateRecord(fileId, (record) => {
|
||||
Object.assign(record, updates);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update file metadata:", error);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
import type { DatabaseConfig } from "@app/services/indexedDBManager";
|
||||
|
||||
/**
|
||||
* A blocked open fires `blocked` and then nothing at all - no success, no error -
|
||||
* until the other connection goes away. Unguarded, the open promise never settles
|
||||
* and every caller hangs SILENTLY: the file library spun forever with an empty
|
||||
* console, which is why this kept being reported as unreproducible.
|
||||
*/
|
||||
|
||||
const config = (name: string, version: number): DatabaseConfig => ({
|
||||
name,
|
||||
version,
|
||||
stores: [{ name: "things", keyPath: "id" }],
|
||||
});
|
||||
|
||||
/** A raw connection on an older version that never yields, i.e. the other tab. */
|
||||
function holdOlderVersion(name: string): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(name, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
if (!request.result.objectStoreNames.contains("things")) {
|
||||
request.result.createObjectStore("things", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("openDatabase — blocked by another connection", () => {
|
||||
test("rejects with something actionable instead of hanging", async () => {
|
||||
expectConsole.warn(/blocked by another connection/);
|
||||
const { indexedDBManager } = await import("@app/services/indexedDBManager");
|
||||
const held = await holdOlderVersion("blocked-db");
|
||||
|
||||
vi.useFakeTimers();
|
||||
const open = indexedDBManager.openDatabase(config("blocked-db", 2));
|
||||
const settled = vi.fn();
|
||||
void open.then(settled, settled);
|
||||
|
||||
// Still pending before the grace period is up: a tab that yields quickly
|
||||
// must not be failed prematurely.
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await expect(open).rejects.toThrow(/blocked by another connection/);
|
||||
|
||||
held.close();
|
||||
});
|
||||
|
||||
test("dedupes concurrent callers onto one connection", async () => {
|
||||
const { indexedDBManager } = await import("@app/services/indexedDBManager");
|
||||
const spy = vi.spyOn(indexedDB, "open");
|
||||
|
||||
// Racing in the same tick is the case registration-after-await could not
|
||||
// dedupe, and only the first request would ever receive `blocked`.
|
||||
const [a, b, c] = await Promise.all([
|
||||
indexedDBManager.openDatabase(config("shared-db", 1)),
|
||||
indexedDBManager.openDatabase(config("shared-db", 1)),
|
||||
indexedDBManager.openDatabase(config("shared-db", 1)),
|
||||
]);
|
||||
|
||||
expect(a).toBe(b);
|
||||
expect(b).toBe(c);
|
||||
expect(
|
||||
spy.mock.calls.filter(([name]) => name === "shared-db"),
|
||||
).toHaveLength(1);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,11 @@ export interface DatabaseConfig {
|
||||
}[];
|
||||
}
|
||||
|
||||
/** How long to wait out another connection before failing an open with something
|
||||
* the user can act on. Rejecting does NOT cancel the request, so a connection
|
||||
* that arrives later is closed rather than held. */
|
||||
const BLOCKED_GRACE_MS = 5000;
|
||||
|
||||
class IndexedDBManager {
|
||||
private static instance: IndexedDBManager;
|
||||
private databases = new Map<string, IDBDatabase>();
|
||||
@@ -47,6 +52,26 @@ class IndexedDBManager {
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
// Registered BEFORE anything async. A map written after a yield point can't
|
||||
// dedupe callers racing into it in the same tick, so every context that opened
|
||||
// this database during boot got its own connection - and per spec only the
|
||||
// FIRST request ever receives `blocked`, leaving the rest waiting on an event
|
||||
// that never comes.
|
||||
const initPromise = this.openWithRecovery(config);
|
||||
this.initPromises.set(config.name, initPromise);
|
||||
|
||||
try {
|
||||
const db = await initPromise;
|
||||
this.databases.set(config.name, db);
|
||||
return db;
|
||||
} catch (error) {
|
||||
this.initPromises.delete(config.name);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** The v6/v7 wipe, kept off {@link openDatabase}'s synchronous registration path. */
|
||||
private async openWithRecovery(config: DatabaseConfig): Promise<IDBDatabase> {
|
||||
// SaaS lineage shipped a v6 and a v7 of stirling-pdf-files whose
|
||||
// upgrade paths corrupted records (separate cursor walks racing in
|
||||
// one versionchange transaction). The SaaS build wipes those
|
||||
@@ -64,18 +89,7 @@ class IndexedDBManager {
|
||||
await this.deleteDatabase(config.name);
|
||||
}
|
||||
}
|
||||
|
||||
const initPromise = this.performDatabaseInit(config);
|
||||
this.initPromises.set(config.name, initPromise);
|
||||
|
||||
try {
|
||||
const db = await initPromise;
|
||||
this.databases.set(config.name, db);
|
||||
return db;
|
||||
} catch (error) {
|
||||
this.initPromises.delete(config.name);
|
||||
throw error;
|
||||
}
|
||||
return this.performDatabaseInit(config);
|
||||
}
|
||||
|
||||
private performDatabaseInit(config: DatabaseConfig): Promise<IDBDatabase> {
|
||||
@@ -83,15 +97,60 @@ class IndexedDBManager {
|
||||
console.log(`Opening IndexedDB: ${config.name} v${config.version}`);
|
||||
const request = indexedDB.open(config.name, config.version);
|
||||
|
||||
// A blocked upgrade fires `blocked` and then NOTHING - no success, no error -
|
||||
// until the other connection goes away. Unguarded, the promise never settles
|
||||
// and every awaiting caller hangs with nothing in the console.
|
||||
let settled = false;
|
||||
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
request.onblocked = () => {
|
||||
console.warn(
|
||||
`Opening ${config.name} is blocked by another connection (another tab on an older version?). ` +
|
||||
`Giving up in ${BLOCKED_GRACE_MS}ms if it doesn't yield.`,
|
||||
);
|
||||
blockedTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(
|
||||
new Error(
|
||||
`Opening ${config.name} was blocked by another connection for ${BLOCKED_GRACE_MS}ms. ` +
|
||||
"Close other tabs of this app and reload.",
|
||||
),
|
||||
);
|
||||
}, BLOCKED_GRACE_MS);
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
clearTimeout(blockedTimer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
console.error(`Failed to open ${config.name}:`, request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
clearTimeout(blockedTimer);
|
||||
const db = request.result;
|
||||
// We already gave up waiting: close it rather than hold a handle nobody
|
||||
// awaits, or we become the next tab's blocker.
|
||||
if (settled) {
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
console.log(`Successfully opened ${config.name}`);
|
||||
|
||||
// Another tab wants a newer schema. Forget BEFORE closing: a cached but
|
||||
// closed handle is worse than none, because every transaction on it throws.
|
||||
db.onversionchange = () => {
|
||||
console.warn(
|
||||
`${config.name}: another tab requested a version change; closing this connection`,
|
||||
);
|
||||
this.databases.delete(config.name);
|
||||
this.initPromises.delete(config.name);
|
||||
db.close();
|
||||
};
|
||||
|
||||
// Set up close handler to clean up our references
|
||||
db.onclose = () => {
|
||||
console.log(`Database ${config.name} closed`);
|
||||
@@ -329,9 +388,36 @@ class IndexedDBManager {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const deleteRequest = indexedDB.deleteDatabase(name);
|
||||
// A delete blocks exactly like an upgrade, and this one is awaited on the
|
||||
// files open path - so an unguarded block hangs the whole storage layer.
|
||||
let settled = false;
|
||||
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
deleteRequest.onerror = () => reject(deleteRequest.error);
|
||||
deleteRequest.onblocked = () => {
|
||||
console.warn(
|
||||
`Deleting ${name} is blocked by another connection; giving up in ${BLOCKED_GRACE_MS}ms.`,
|
||||
);
|
||||
blockedTimer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(
|
||||
new Error(
|
||||
`Deleting ${name} was blocked by another connection for ${BLOCKED_GRACE_MS}ms.`,
|
||||
),
|
||||
);
|
||||
}, BLOCKED_GRACE_MS);
|
||||
};
|
||||
|
||||
deleteRequest.onerror = () => {
|
||||
clearTimeout(blockedTimer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(deleteRequest.error);
|
||||
};
|
||||
deleteRequest.onsuccess = () => {
|
||||
clearTimeout(blockedTimer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
console.log(`Deleted database: ${name}`);
|
||||
resolve();
|
||||
};
|
||||
@@ -343,17 +429,32 @@ class IndexedDBManager {
|
||||
*/
|
||||
async getDatabaseVersion(name: string): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
// This probe runs BEFORE the guarded open, and a versionless open can be
|
||||
// delayed indefinitely by another tab mid-versionchange. Unknown after the
|
||||
// grace period beats hanging every storage consumer: the real open that
|
||||
// follows has its own blocked guard and a message the user can act on.
|
||||
const giveUp = setTimeout(() => {
|
||||
console.warn(
|
||||
`Version probe for ${name} did not answer in ${BLOCKED_GRACE_MS}ms; proceeding without it.`,
|
||||
);
|
||||
resolve(null);
|
||||
}, BLOCKED_GRACE_MS);
|
||||
const request = indexedDB.open(name);
|
||||
request.onsuccess = () => {
|
||||
clearTimeout(giveUp);
|
||||
const db = request.result;
|
||||
const version = db.version;
|
||||
db.close();
|
||||
resolve(version);
|
||||
};
|
||||
request.onerror = () => resolve(null);
|
||||
request.onerror = () => {
|
||||
clearTimeout(giveUp);
|
||||
resolve(null);
|
||||
};
|
||||
request.onupgradeneeded = () => {
|
||||
// Cancel the upgrade
|
||||
request.transaction?.abort();
|
||||
clearTimeout(giveUp);
|
||||
resolve(null);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* A WASM instantiate that fails must reject, not hang. `instantiateWasm` reports
|
||||
* success by callback, so a swallowed rejection leaves `init()` pending and takes
|
||||
* every thumbnail, page parse and form read with it - silently.
|
||||
*/
|
||||
|
||||
const init = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@embedpdf/pdfium", () => ({ init }));
|
||||
|
||||
const wasmModule = vi.hoisted(() => ({}) as WebAssembly.Module);
|
||||
vi.mock("@app/services/wasmPrecompiler", () => ({
|
||||
pdfiumWasmModulePromise: Promise.resolve(wasmModule),
|
||||
startEagerWasmCompilation: () => {},
|
||||
pdfiumWasmUrl: "http://localhost/pdfium.wasm",
|
||||
}));
|
||||
|
||||
/** emscripten's contract: it calls instantiateWasm and waits to be called back. */
|
||||
function emscriptenInit(
|
||||
instantiate: (imports: object, ok: () => void) => void,
|
||||
) {
|
||||
return new Promise(() => {
|
||||
instantiate({}, () => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadService() {
|
||||
vi.resetModules();
|
||||
return await import("@app/services/pdfiumService");
|
||||
}
|
||||
|
||||
describe("pdfium bootstrap", () => {
|
||||
test("rejects when instantiating the pre-compiled module fails", async () => {
|
||||
const failure = new Error("LinkError: import mismatch");
|
||||
vi.spyOn(WebAssembly, "instantiate").mockRejectedValue(failure as never);
|
||||
init.mockImplementation((overrides: Record<string, never>) =>
|
||||
emscriptenInit(
|
||||
overrides.instantiateWasm as unknown as (
|
||||
imports: object,
|
||||
ok: () => void,
|
||||
) => void,
|
||||
),
|
||||
);
|
||||
|
||||
const { getPdfiumModule } = await loadService();
|
||||
|
||||
// Before the fix this never settled, so the test timed out.
|
||||
await expect(getPdfiumModule()).rejects.toThrow(/LinkError/);
|
||||
});
|
||||
|
||||
test("a failed load isn't cached, so the next call retries", async () => {
|
||||
const instantiate = vi
|
||||
.spyOn(WebAssembly, "instantiate")
|
||||
.mockRejectedValueOnce(new Error("transient") as never)
|
||||
.mockResolvedValue({} as never);
|
||||
const ready = { PDFiumExt_Init: () => {} };
|
||||
init.mockImplementation(
|
||||
(overrides: Record<string, never>) =>
|
||||
new Promise((resolve) => {
|
||||
const instantiateWasm = overrides.instantiateWasm as unknown as (
|
||||
imports: object,
|
||||
ok: () => void,
|
||||
) => void;
|
||||
instantiateWasm({}, () => resolve(ready));
|
||||
}),
|
||||
);
|
||||
|
||||
const { getPdfiumModule } = await loadService();
|
||||
|
||||
await expect(getPdfiumModule()).rejects.toThrow(/transient/);
|
||||
await expect(getPdfiumModule()).resolves.toBe(ready);
|
||||
expect(instantiate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -80,17 +80,24 @@ function wasmUrl(): string {
|
||||
* This is the low-level PDFium WASM interface with all C functions wrapped.
|
||||
* Prefer `withDocument()` for document-scoped work.
|
||||
*/
|
||||
export async function getPdfiumModule(): Promise<WrappedPdfiumModule> {
|
||||
if (_module) return _module;
|
||||
if (!_initPromise) {
|
||||
// Ensure eager compilation has started if PDF service is requested before idle timeout
|
||||
startEagerWasmCompilation();
|
||||
/** Reuses the WASM pre-compiled at boot. Every failure must reach this promise:
|
||||
* `instantiateWasm` reports success by callback, so a rejection inside it leaves
|
||||
* `init()` pending forever - and with it every thumbnail, parse and form read. */
|
||||
async function initPdfiumModule(): Promise<WrappedPdfiumModule> {
|
||||
// Ensure eager compilation has started if PDF service is requested before idle timeout
|
||||
startEagerWasmCompilation();
|
||||
|
||||
const overrides: PdfiumModuleOverrides = {
|
||||
locateFile: () => wasmUrl(),
|
||||
};
|
||||
const overrides: PdfiumModuleOverrides = { locateFile: () => wasmUrl() };
|
||||
const precompiled = await pdfiumWasmModulePromise;
|
||||
|
||||
// Eagerly reuse pre-compiled WASM module from app boot if available
|
||||
let reportFailure: (error: unknown) => void = () => {};
|
||||
const instantiateFailed = new Promise<never>((_, reject) => {
|
||||
reportFailure = reject;
|
||||
});
|
||||
|
||||
// No pre-compiled module: leave instantiateWasm alone so emscripten fetches the
|
||||
// WASM itself and rejects init() on failure, instead of a fallback that can't.
|
||||
if (precompiled) {
|
||||
overrides.instantiateWasm = (
|
||||
imports: WebAssembly.Imports,
|
||||
successCallback: (
|
||||
@@ -98,40 +105,34 @@ export async function getPdfiumModule(): Promise<WrappedPdfiumModule> {
|
||||
module: WebAssembly.Module,
|
||||
) => void,
|
||||
) => {
|
||||
pdfiumWasmModulePromise
|
||||
.then((wasmModule) => {
|
||||
if (wasmModule) {
|
||||
return WebAssembly.instantiate(wasmModule, imports).then(
|
||||
(instance) => {
|
||||
successCallback(instance, wasmModule);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
throw new Error("No pre-compiled WASM module found");
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.warn(
|
||||
"Eager WebAssembly instantiation failed, falling back to streaming compilation:",
|
||||
err,
|
||||
);
|
||||
WebAssembly.instantiateStreaming(fetch(wasmUrl()), imports).then(
|
||||
(result) => {
|
||||
successCallback(result.instance, result.module);
|
||||
},
|
||||
);
|
||||
});
|
||||
WebAssembly.instantiate(precompiled, imports)
|
||||
.then((instance) => successCallback(instance, precompiled))
|
||||
.catch(reportFailure);
|
||||
};
|
||||
}
|
||||
|
||||
_initPromise = init(overrides as Partial<PdfiumModule>).then((m) => {
|
||||
// Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up
|
||||
try {
|
||||
m.PDFiumExt_Init();
|
||||
} catch {
|
||||
/* already initialized */
|
||||
}
|
||||
_module = m;
|
||||
return m;
|
||||
const m = await Promise.race([
|
||||
init(overrides as Partial<PdfiumModule>),
|
||||
instantiateFailed,
|
||||
]);
|
||||
// Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up
|
||||
try {
|
||||
m.PDFiumExt_Init();
|
||||
} catch {
|
||||
/* already initialized */
|
||||
}
|
||||
_module = m;
|
||||
return m;
|
||||
}
|
||||
|
||||
export async function getPdfiumModule(): Promise<WrappedPdfiumModule> {
|
||||
if (_module) return _module;
|
||||
if (!_initPromise) {
|
||||
_initPromise = initPdfiumModule().catch((error: unknown) => {
|
||||
// Don't cache the failure: every PDF feature in the app goes through here,
|
||||
// so a transient WASM fetch would take them all down for the session.
|
||||
_initPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return _initPromise;
|
||||
|
||||
@@ -2,6 +2,10 @@ import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
import { installFailOnConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// jsdom is missing the same APIs WebKit is, so tests must agree with the
|
||||
// browser. Same module `src/index.tsx` installs.
|
||||
import "@app/utils/engineShims";
|
||||
|
||||
installFailOnConsole();
|
||||
|
||||
// Mock localStorage for tests
|
||||
|
||||
@@ -68,6 +68,8 @@ const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
// Mock only essential services that are actually called by the tests
|
||||
vi.mock("../../services/fileStorage", () => ({
|
||||
// FileContext subscribes to this to drop files whose bytes are unreadable.
|
||||
onRecordUnreadable: () => () => {},
|
||||
fileStorage: {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||
|
||||
@@ -66,6 +66,8 @@ const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
// Mock only essential services that are actually called by the tests
|
||||
vi.mock("../../services/fileStorage", () => ({
|
||||
// FileContext subscribes to this to drop files whose bytes are unreadable.
|
||||
onRecordUnreadable: () => () => {},
|
||||
fileStorage: {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
|
||||
|
||||
@@ -131,6 +131,9 @@ export interface MockAppApiOptions {
|
||||
languages?: string[];
|
||||
/** Default locale. */
|
||||
defaultLocale?: string;
|
||||
/** Advertise server-side storage. Off by default - a spec that stubs or
|
||||
* blocks `/api/v1/storage/files` must set this or the route is never hit. */
|
||||
storageEnabled?: boolean;
|
||||
/** Merge overrides into the endpoint availability map. */
|
||||
endpointsAvailability?: Record<string, { enabled: boolean }>;
|
||||
/** Backend probe status. Set to `"DOWN"` to exercise offline-mode UI. */
|
||||
@@ -156,6 +159,7 @@ export async function mockAppApis(
|
||||
},
|
||||
languages = ["en-US"],
|
||||
defaultLocale = "en-US",
|
||||
storageEnabled = false,
|
||||
endpointsAvailability = {},
|
||||
backendStatus = "UP",
|
||||
} = opts;
|
||||
@@ -173,6 +177,7 @@ export async function mockAppApis(
|
||||
isAdmin,
|
||||
languages,
|
||||
defaultLocale,
|
||||
storageEnabled,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -230,4 +230,7 @@ test.describe("Compare tool slot selection", () => {
|
||||
page.locator('[data-testid="compare-slot-comparison"]'),
|
||||
).toHaveAttribute("data-slot-state", "empty");
|
||||
});
|
||||
|
||||
// These specs stop at slot state. Actually running a comparison lives in
|
||||
// `engine-capabilities.spec.ts`, which is cross-browser in PR CI.
|
||||
});
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/** Runs on all three engines in PR CI, asserting on evidence that can only exist
|
||||
* if the engine did the work. Keep small - it is paid for three times per PR. */
|
||||
|
||||
import path from "path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
import { dismissTourTooltip, uploadFiles } from "@app/tests/helpers/ui-helpers";
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
|
||||
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
|
||||
const PDF_A = path.join(FIXTURES_DIR, "compare_sample_a.pdf");
|
||||
const PDF_B = path.join(FIXTURES_DIR, "compare_sample_b.pdf");
|
||||
|
||||
/** A missing global or prototype method always surfaces as one of these.
|
||||
* Matching the shape keeps benign engine noise out (console-clean.spec.ts). */
|
||||
const MISSING_API_ERROR =
|
||||
/is not a function|is not a constructor|undefined is not an object|has no method/i;
|
||||
|
||||
/** Collect the "this engine lacks an API we used" errors seen on the page. */
|
||||
function recordMissingApiErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error: Error) => {
|
||||
const text = String(error);
|
||||
if (MISSING_API_ERROR.test(text)) errors.push(text);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function fillCompareSlot(
|
||||
page: Page,
|
||||
role: "base" | "comparison",
|
||||
filePath: string,
|
||||
) {
|
||||
await page
|
||||
.getByTestId(`compare-slot-${role}-add-input`)
|
||||
.setInputFiles(filePath);
|
||||
await expect(
|
||||
page.locator(`[data-testid="compare-slot-${role}"]`),
|
||||
).toHaveAttribute("data-slot-state", "filled", { timeout: 20_000 });
|
||||
// The upload modal's overlay outlives its close transition and eats clicks.
|
||||
await page
|
||||
.locator(".mantine-Modal-overlay")
|
||||
.waitFor({ state: "detached", timeout: 5_000 })
|
||||
.catch(() => {
|
||||
/* already gone */
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("engine capabilities", { tag: "@engine-capability" }, () => {
|
||||
test("extracts PDF text and completes a comparison", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const missingApis = recordMissingApiErrors(page);
|
||||
|
||||
await page.locator('[data-tour="tool-button-compare"]').first().click();
|
||||
await page.waitForSelector('[data-testid="compare-slot-base"]', {
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await fillCompareSlot(page, "base", PDF_A);
|
||||
await fillCompareSlot(page, "comparison", PDF_B);
|
||||
|
||||
// By test id: `name` matches as a substring, so "Compare" also hits the
|
||||
// tool button that opened this panel.
|
||||
await page.getByTestId("compare-execute").click();
|
||||
|
||||
// Counted results, not headings: an extraction returning nothing still
|
||||
// renders empty panes, which is how the WebKit failure looked like success.
|
||||
const deletions = page.getByText(/Deletions \((\d+)\)/);
|
||||
const additions = page.getByText(/Additions \((\d+)\)/);
|
||||
await expect(deletions).toBeVisible({ timeout: 60_000 });
|
||||
await expect(additions).toBeVisible();
|
||||
expect(await deletions.innerText()).not.toMatch(/\(0\)/);
|
||||
expect(await additions.innerText()).not.toMatch(/\(0\)/);
|
||||
|
||||
expect(missingApis, "no missing-API errors during comparison").toEqual([]);
|
||||
});
|
||||
|
||||
test("rasterises page thumbnails via the PDF engine", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const missingApis = recordMissingApiErrors(page);
|
||||
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
await dismissTourTooltip(page);
|
||||
|
||||
// A page thumbnail only exists if the WASM engine loaded, rendered and
|
||||
// encoded. When it fails the grid still renders, just with no <img>.
|
||||
await page.getByText("PDF Multi Tool", { exact: true }).first().click();
|
||||
|
||||
const thumbnail = page
|
||||
.locator("[data-page-id] img[data-original-rotation]")
|
||||
.first();
|
||||
await expect(thumbnail).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// An empty encode still yields a src; require enough payload to be real.
|
||||
const src = await thumbnail.getAttribute("src");
|
||||
expect(src ?? "").toMatch(/^data:image\//);
|
||||
expect(src?.length ?? 0).toBeGreaterThan(1_000);
|
||||
|
||||
expect(missingApis, "no missing-API errors during thumbnailing").toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("reads a stored file's bytes back after a reload", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const missingApis = recordMissingApiErrors(page);
|
||||
|
||||
await uploadFiles(page, SAMPLE_PDF);
|
||||
|
||||
// Full reload: FileContext rehydrates from IndexedDB, not from memory.
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
|
||||
const restored = page.locator(".file-sidebar-file-item").first();
|
||||
await expect(restored).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Rendering it is the assertion that matters: the metadata record survives
|
||||
// even when the bytes were never stored, so a filename proves nothing.
|
||||
await restored.hover();
|
||||
await restored
|
||||
.locator(".file-sidebar-eye-btn")
|
||||
.click({ timeout: 15_000, force: true });
|
||||
|
||||
const firstPage = page.locator('[data-page-index="0"]').first();
|
||||
await expect(firstPage).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
// A tile that decoded has non-zero naturalWidth. A blob stored but not
|
||||
// readable back resolves to nothing, and renders as an empty page.
|
||||
const tile = firstPage.locator('img[src^="blob:"]').first();
|
||||
await expect(tile).toBeAttached({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => tile.evaluate((img: HTMLImageElement) => img.naturalWidth), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
expect(missingApis, "no missing-API errors after rehydration").toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/** The sidebar and file pickers must reach a resting state even when storage
|
||||
* can't be read. Blocks the database for real, then asserts the UI recovers. */
|
||||
|
||||
import path from "path";
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
|
||||
const SAMPLE_PDF = path.join(
|
||||
import.meta.dirname,
|
||||
"../test-fixtures/sample.pdf",
|
||||
);
|
||||
|
||||
/** The app opens `stirling-pdf-files` at this version; block it from below. */
|
||||
const BLOCKING_VERSION = 8;
|
||||
|
||||
/** Park a connection on an older version and never yield it, the way a tab
|
||||
* running an older build does, so the app's upgrade can't proceed. */
|
||||
async function blockTheFilesDatabase(page: import("@playwright/test").Page) {
|
||||
await page.addInitScript((version: number) => {
|
||||
const request = indexedDB.open("stirling-pdf-files", version);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains("files")) {
|
||||
db.createObjectStore("files", { keyPath: "id" });
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
// Deliberately NO onversionchange handler: hold the database open.
|
||||
(window as unknown as { __blocker?: IDBDatabase }).__blocker =
|
||||
request.result;
|
||||
};
|
||||
}, BLOCKING_VERSION);
|
||||
}
|
||||
|
||||
/** Closing the context is NOT enough: WebKit keeps origin databases between
|
||||
* contexts, so a stray v8 makes a random later spec fail. */
|
||||
async function unblockTheFilesDatabase(page: import("@playwright/test").Page) {
|
||||
await page.evaluate(async () => {
|
||||
const holder = window as unknown as { __blocker?: IDBDatabase };
|
||||
holder.__blocker?.close();
|
||||
holder.__blocker = undefined;
|
||||
await new Promise<void>((resolve) => {
|
||||
const request = indexedDB.deleteDatabase("stirling-pdf-files");
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => resolve();
|
||||
request.onblocked = () => resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("the sidebar stops loading even when the file library is unreadable", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Allow for the manager's blocked-upgrade grace period plus app boot.
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const warnings: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "warning" || message.type() === "error") {
|
||||
warnings.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await blockTheFilesDatabase(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
|
||||
try {
|
||||
// Storage is not on the critical path for rendering the workbench.
|
||||
await page.waitForSelector('[data-tour="tool-button-compare"]', {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// The spinner is the whole bug: it must clear once the manager gives up.
|
||||
await expect(page.locator(".file-sidebar-loading")).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// The reason must be discoverable - a silent console made the original
|
||||
// report impossible to diagnose.
|
||||
expect(
|
||||
warnings.some((text) => /blocked by another open connection/i.test(text)),
|
||||
`expected a blocked-database warning, got: ${JSON.stringify(warnings)}`,
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await unblockTheFilesDatabase(page);
|
||||
}
|
||||
});
|
||||
|
||||
// `loadRecentFiles` only fetches `/api/v1/storage/files` when app-config
|
||||
// advertises `storageEnabled`; without it the route below is never requested.
|
||||
test.describe("with server-side storage enabled", () => {
|
||||
test.use({ stubOptions: { storageEnabled: true } });
|
||||
|
||||
test("the picker's Workbench tab lists files while saved files are still loading", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
// Hold the saved-files read open so the picker stays stuck all test.
|
||||
let sawSavedFilesRequest = false;
|
||||
await page.route("**/api/v1/storage/files", async () => {
|
||||
sawSavedFilesRequest = true;
|
||||
await new Promise(() => {
|
||||
/* never responds */
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector('[data-tour="tool-button-compare"]', {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Put a file in the workbench so the Workbench tab has something to show.
|
||||
await page.getByTestId("files-button").click();
|
||||
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF);
|
||||
await expect(page.locator(".file-sidebar-file-item").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.locator('[data-tour="tool-button-compare"]').first().click();
|
||||
await page.getByTestId("compare-slot-base-add").click();
|
||||
|
||||
// The picker must actually be stuck, or the rest proves nothing.
|
||||
const picker = page.locator(".mantine-Popover-dropdown");
|
||||
await expect(picker).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => sawSavedFilesRequest, { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
|
||||
// Workbench lists from memory, so a stuck read must not hide it. Scope to
|
||||
// the popover: the sidebar row behind it matches the same locator.
|
||||
await picker
|
||||
.getByRole("button", { name: "Workbench", exact: true })
|
||||
.click();
|
||||
await expect(
|
||||
picker.getByRole("button", { name: /sample\.pdf/ }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
} from "@app/tools/formFill/FormFillContext";
|
||||
import { useNavigation } from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { useAllFiles, useFileState } from "@app/contexts/FileContext";
|
||||
import { Skeleton } from "@mantine/core";
|
||||
import { isStirlingFile, getFormFillFileId } from "@app/types/fileContext";
|
||||
import type { BaseToolProps } from "@app/types/tool";
|
||||
@@ -124,7 +124,7 @@ const _MODE_TABS: ModeTabDef[] = [
|
||||
const FormFill = (_props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedTool } = useNavigation();
|
||||
const { selectors, state: fileState } = useFileState();
|
||||
const { state: fileState } = useFileState();
|
||||
|
||||
const {
|
||||
state: formState,
|
||||
@@ -178,7 +178,9 @@ const FormFill = (_props: BaseToolProps) => {
|
||||
const isDirtyRef = useRef(formState.isDirty);
|
||||
isDirtyRef.current = formState.isDirty;
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
// Subscribing read: getFiles() during render doesn't re-run when the workbench
|
||||
// changes, so the panel kept showing the pre-hydration (or pre-version) file.
|
||||
const { files: activeFiles } = useAllFiles();
|
||||
const selectedFileIds = fileState.ui.selectedFileIds;
|
||||
const currentFile = useMemo(() => {
|
||||
if (activeFiles.length === 0) return null;
|
||||
|
||||
@@ -61,6 +61,12 @@ export interface StirlingFileStub extends BaseFileMetadata {
|
||||
* unclassified files / non-SaaS builds.
|
||||
*/
|
||||
classificationLabels?: string[];
|
||||
/**
|
||||
* This session proved the stored bytes unreadable (WebKit losing a blob's
|
||||
* backing store). The row renders as "data lost" instead of pretending the
|
||||
* file can open; re-uploading is the only recovery.
|
||||
*/
|
||||
dataUnavailable?: boolean;
|
||||
// Note: File object stored in provider ref, not in state
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
lossyEncodeOptions,
|
||||
resetCanvasEncodingProbe,
|
||||
} from "@app/utils/canvasImageEncoding";
|
||||
|
||||
/** An engine that can't encode a format returns PNG instead of throwing, so
|
||||
* only the returned Blob's `type` reveals what happened. */
|
||||
|
||||
/** Stand in for OffscreenCanvas, honouring only the given MIME types. */
|
||||
function stubOffscreenCanvas(honoured: string[]): void {
|
||||
class FakeOffscreenCanvas {
|
||||
constructor(
|
||||
public width: number,
|
||||
public height: number,
|
||||
) {}
|
||||
|
||||
getContext() {
|
||||
return { fillStyle: "", fillRect: () => {} };
|
||||
}
|
||||
|
||||
convertToBlob({ type }: { type: string }) {
|
||||
// Per spec: an unsupported type silently serialises as PNG.
|
||||
const actual = honoured.includes(type) ? type : "image/png";
|
||||
return Promise.resolve(new Blob([new Uint8Array([0])], { type: actual }));
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("OffscreenCanvas", FakeOffscreenCanvas);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
resetCanvasEncodingProbe();
|
||||
});
|
||||
|
||||
describe("lossyEncodeOptions", () => {
|
||||
it("uses WebP when the engine really encodes it", async () => {
|
||||
stubOffscreenCanvas(["image/webp", "image/jpeg", "image/png"]);
|
||||
await expect(lossyEncodeOptions()).resolves.toEqual({
|
||||
type: "image/webp",
|
||||
quality: 0.85,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to JPEG when a WebP request silently yields PNG", async () => {
|
||||
// WebKit's actual behaviour: no WebP encoder, no error either.
|
||||
stubOffscreenCanvas(["image/jpeg", "image/png"]);
|
||||
await expect(lossyEncodeOptions()).resolves.toEqual({
|
||||
type: "image/jpeg",
|
||||
quality: 0.85,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to PNG when no lossy format is honoured", async () => {
|
||||
stubOffscreenCanvas(["image/png"]);
|
||||
await expect(lossyEncodeOptions()).resolves.toEqual({
|
||||
type: "image/png",
|
||||
quality: 0.85,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the caller's quality through", async () => {
|
||||
stubOffscreenCanvas(["image/webp", "image/png"]);
|
||||
await expect(lossyEncodeOptions(0.5)).resolves.toEqual({
|
||||
type: "image/webp",
|
||||
quality: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("probes once and reuses the answer", async () => {
|
||||
stubOffscreenCanvas(["image/webp", "image/png"]);
|
||||
const spy = vi.spyOn(
|
||||
(globalThis as unknown as { OffscreenCanvas: { prototype: object } })
|
||||
.OffscreenCanvas.prototype as { convertToBlob: () => unknown },
|
||||
"convertToBlob",
|
||||
);
|
||||
await lossyEncodeOptions();
|
||||
const afterFirst = spy.mock.calls.length;
|
||||
await lossyEncodeOptions();
|
||||
expect(spy.mock.calls.length).toBe(afterFirst);
|
||||
});
|
||||
|
||||
it("returns PNG where there is no OffscreenCanvas at all", async () => {
|
||||
vi.stubGlobal("OffscreenCanvas", undefined);
|
||||
await expect(lossyEncodeOptions()).resolves.toEqual({
|
||||
type: "image/png",
|
||||
quality: 0.85,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/** `convertToBlob` silently serialises to PNG for a format it can't encode
|
||||
* (WebKit, WebP), so only the returned `type` reveals it. Probe once per realm. */
|
||||
|
||||
/** Candidates in preference order, best compression first. PNG always works. */
|
||||
const LOSSY_CANDIDATES = ["image/webp", "image/jpeg"] as const;
|
||||
const PNG_TYPE = "image/png";
|
||||
|
||||
let probe: Promise<string> | null = null;
|
||||
|
||||
async function honoursType(type: string, quality: number): Promise<boolean> {
|
||||
try {
|
||||
const canvas = new OffscreenCanvas(2, 2);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return false;
|
||||
// JPEG has no alpha, and the pixels are thrown away either way.
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, 2, 2);
|
||||
const blob = await canvas.convertToBlob({ type, quality });
|
||||
return blob.type === type;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function detectLossyType(quality: number): Promise<string> {
|
||||
if (typeof OffscreenCanvas === "undefined") return PNG_TYPE;
|
||||
for (const candidate of LOSSY_CANDIDATES) {
|
||||
if (await honoursType(candidate, quality)) return candidate;
|
||||
}
|
||||
return PNG_TYPE;
|
||||
}
|
||||
|
||||
/** Encode options for a page raster: the best lossy format this engine really
|
||||
* supports, PNG only if it supports none. `quality` is ignored by PNG. */
|
||||
export function lossyEncodeOptions(
|
||||
quality = 0.85,
|
||||
): Promise<ImageEncodeOptions> {
|
||||
probe ??= detectLossyType(quality);
|
||||
return probe.then((type) => ({ type, quality }));
|
||||
}
|
||||
|
||||
/** Reset the cached probe. Tests only. */
|
||||
export function resetCanvasEncodingProbe(): void {
|
||||
probe = null;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Browser APIs the app is entitled to assume exist, stood in where an engine
|
||||
// omits them. Import once, first, before anything that might use them.
|
||||
import "@app/utils/patchReadableStreamAsyncIterator";
|
||||
import "@app/utils/patchRequestIdleCallback";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { patchReadableStreamAsyncIterator } from "@app/utils/patchReadableStreamAsyncIterator";
|
||||
|
||||
/** Node implements the API, so remove it to exercise the shim, then restore. */
|
||||
const KEY: PropertyKey = Symbol.asyncIterator;
|
||||
const proto: object = ReadableStream.prototype;
|
||||
let native: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
native = Object.getOwnPropertyDescriptor(proto, KEY);
|
||||
Reflect.deleteProperty(proto, KEY);
|
||||
patchReadableStreamAsyncIterator();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (native) Object.defineProperty(proto, KEY, native);
|
||||
});
|
||||
|
||||
function streamOf(...chunks: number[]): ReadableStream<number> {
|
||||
return new ReadableStream<number>({
|
||||
start(controller) {
|
||||
chunks.forEach((c) => controller.enqueue(c));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function failingStream(error: Error): ReadableStream<number> {
|
||||
return new ReadableStream<number>({
|
||||
start(controller) {
|
||||
controller.enqueue(1);
|
||||
controller.error(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("patchReadableStreamAsyncIterator", () => {
|
||||
it("drains a stream with for await, then unlocks it", async () => {
|
||||
const stream = streamOf(1, 2, 3);
|
||||
const seen: number[] = [];
|
||||
for await (const chunk of stream) seen.push(chunk);
|
||||
|
||||
expect(seen).toEqual([1, 2, 3]);
|
||||
expect(stream.locked).toBe(false);
|
||||
});
|
||||
|
||||
// The reader must be released in the read's error steps. `for await` does not
|
||||
// call `return()` when `next()` rejects, so nothing else would ever unlock it.
|
||||
it("releases the lock when the stream errors mid-read", async () => {
|
||||
const boom = new Error("boom");
|
||||
const stream = failingStream(boom);
|
||||
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const _ of stream) {
|
||||
/* drain until it throws */
|
||||
}
|
||||
})(),
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
expect(stream.locked).toBe(false);
|
||||
});
|
||||
|
||||
it("cancels and unlocks when the consumer breaks early", async () => {
|
||||
const stream = streamOf(1, 2, 3);
|
||||
for await (const chunk of stream) {
|
||||
expect(chunk).toBe(1);
|
||||
break;
|
||||
}
|
||||
expect(stream.locked).toBe(false);
|
||||
});
|
||||
|
||||
// Native short-circuits once finished; throwing "reader owned by no readable
|
||||
// stream" would break Array.fromAsync and defensive `return()` in a finally.
|
||||
it("keeps reporting done after the stream is drained", async () => {
|
||||
const iterator = streamOf(1)[Symbol.asyncIterator]();
|
||||
await iterator.next();
|
||||
|
||||
await expect(iterator.next()).resolves.toEqual({
|
||||
done: true,
|
||||
value: undefined,
|
||||
});
|
||||
await expect(iterator.return?.()).resolves.toMatchObject({ done: true });
|
||||
});
|
||||
|
||||
it("leaves a real implementation alone", () => {
|
||||
if (native) Object.defineProperty(proto, KEY, native);
|
||||
const before = Object.getOwnPropertyDescriptor(proto, KEY);
|
||||
patchReadableStreamAsyncIterator();
|
||||
expect(Object.getOwnPropertyDescriptor(proto, KEY)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// WebKit ships no `ReadableStream[Symbol.asyncIterator]`, and pdf.js reads its
|
||||
// text stream with `for await`, so all text extraction threw on Safari.
|
||||
|
||||
export function patchReadableStreamAsyncIterator(): void {
|
||||
if (typeof ReadableStream === "undefined") return;
|
||||
if (Symbol.asyncIterator in ReadableStream.prototype) return;
|
||||
|
||||
Object.defineProperty(ReadableStream.prototype, Symbol.asyncIterator, {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: function <T>(this: ReadableStream<T>): AsyncIterableIterator<T> {
|
||||
const reader = this.getReader();
|
||||
// Releasing must be idempotent and must NOT happen after a successful
|
||||
// read - the next `read()` on a released reader throws.
|
||||
let finished = false;
|
||||
const release = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
reader.releaseLock();
|
||||
};
|
||||
return {
|
||||
async next(): Promise<IteratorResult<T>> {
|
||||
// Spec short-circuits once finished; throwing here would break any
|
||||
// consumer that calls `next()` or `return()` defensively.
|
||||
if (finished) return { done: true, value: undefined };
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
release();
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
return { done: false, value };
|
||||
} catch (error) {
|
||||
// The spec releases the reader in the read request's error steps.
|
||||
// Without this an errored stream stays locked for good: `for await`
|
||||
// does not call `return()` when `next()` rejects.
|
||||
release();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
async return(value?: unknown): Promise<IteratorResult<T>> {
|
||||
if (finished) return { done: true, value: value as T };
|
||||
try {
|
||||
await reader.cancel();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
return { done: true, value: value as T };
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
patchReadableStreamAsyncIterator();
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { patchRequestIdleCallback } from "@app/utils/patchRequestIdleCallback";
|
||||
|
||||
/** Two things must hold: it stands in where the API is absent, and it never
|
||||
* displaces a real implementation. */
|
||||
|
||||
type IdleGlobals = {
|
||||
requestIdleCallback?: unknown;
|
||||
cancelIdleCallback?: unknown;
|
||||
};
|
||||
|
||||
const globals = globalThis as IdleGlobals;
|
||||
|
||||
let saved: IdleGlobals;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
saved = {
|
||||
requestIdleCallback: globals.requestIdleCallback,
|
||||
cancelIdleCallback: globals.cancelIdleCallback,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
globals.requestIdleCallback = saved.requestIdleCallback;
|
||||
globals.cancelIdleCallback = saved.cancelIdleCallback;
|
||||
});
|
||||
|
||||
describe("patchRequestIdleCallback", () => {
|
||||
it("stands in where the engine has no requestIdleCallback", () => {
|
||||
delete globals.requestIdleCallback;
|
||||
delete globals.cancelIdleCallback;
|
||||
patchRequestIdleCallback();
|
||||
|
||||
const task = vi.fn();
|
||||
requestIdleCallback(task);
|
||||
|
||||
// Asynchronous: never runs on the caller's own turn.
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
// With no deadline given, it yields briefly and then runs.
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
const deadline = task.mock.calls[0][0] as IdleDeadline;
|
||||
expect(deadline.didTimeout).toBe(false);
|
||||
expect(deadline.timeRemaining()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never runs later than the deadline the caller asked for", () => {
|
||||
delete globals.requestIdleCallback;
|
||||
patchRequestIdleCallback();
|
||||
|
||||
const task = vi.fn();
|
||||
requestIdleCallback(task, { timeout: 50 });
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// `src/index.tsx` asks for 2000ms so the pdfium WASM compile doesn't land on
|
||||
// top of the app's first renders.
|
||||
it("does not run background work earlier than the caller allowed for", () => {
|
||||
delete globals.requestIdleCallback;
|
||||
patchRequestIdleCallback();
|
||||
|
||||
const task = vi.fn();
|
||||
requestIdleCallback(task, { timeout: 2000 });
|
||||
vi.advanceTimersByTime(1999);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels a scheduled task by handle", () => {
|
||||
delete globals.requestIdleCallback;
|
||||
delete globals.cancelIdleCallback;
|
||||
patchRequestIdleCallback();
|
||||
|
||||
const task = vi.fn();
|
||||
cancelIdleCallback(requestIdleCallback(task));
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a real implementation alone", () => {
|
||||
const native = vi.fn();
|
||||
globals.requestIdleCallback = native;
|
||||
patchRequestIdleCallback();
|
||||
expect(globals.requestIdleCallback).toBe(native);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// WebKit ships no `requestIdleCallback`. The shim honours the async/deadline/
|
||||
// cancel contract, not the *idle* part, and is window-only like the real API.
|
||||
|
||||
/** Short delay before running, so the current turn's work drains first. */
|
||||
const YIELD_MS = 200;
|
||||
|
||||
/** Idle-deadline shape for the timer stand-in. A small positive budget keeps a
|
||||
* `while (timeRemaining() > 0)` loop doing one chunk rather than spinning. */
|
||||
function makeDeadline(): IdleDeadline {
|
||||
return { didTimeout: false, timeRemaining: () => 1 };
|
||||
}
|
||||
|
||||
export function patchRequestIdleCallback(): void {
|
||||
if (typeof globalThis === "undefined") return;
|
||||
const target = globalThis as typeof globalThis & {
|
||||
requestIdleCallback?: typeof requestIdleCallback;
|
||||
cancelIdleCallback?: typeof cancelIdleCallback;
|
||||
};
|
||||
if (typeof target.requestIdleCallback === "function") return;
|
||||
|
||||
target.requestIdleCallback = (
|
||||
callback: IdleRequestCallback,
|
||||
options?: IdleRequestOptions,
|
||||
): number =>
|
||||
setTimeout(
|
||||
() => callback(makeDeadline()),
|
||||
// Honour the caller's deadline: `timeout` is them saying how long this may
|
||||
// wait, so firing earlier lands background work on top of startup.
|
||||
options?.timeout ?? YIELD_MS,
|
||||
) as unknown as number;
|
||||
|
||||
target.cancelIdleCallback = (handle: number): void => {
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}
|
||||
|
||||
patchRequestIdleCallback();
|
||||
@@ -31,6 +31,12 @@ export function calculateScaleFromFileSize(fileSize: number): number {
|
||||
/** PDFium error code 4 = password required (encrypted PDF). */
|
||||
const PDFIUM_ERR_PASSWORD = 4;
|
||||
|
||||
/** Callers still get a placeholder, but log the cause: an empty thumbnail is
|
||||
* indistinguishable from "no raster preview", so an outage hides as a nicety. */
|
||||
function reportThumbnailFailure(file: File, error: unknown): void {
|
||||
console.warn(`Thumbnail generation failed for ${file.name}:`, error);
|
||||
}
|
||||
|
||||
/** PDFs at or above this size never get a full-buffer client-side parse
|
||||
* (renderer OOM) - only the linearized-prefix attempt below. */
|
||||
export const LARGE_PDF_PARSE_LIMIT = 100 * 1024 * 1024;
|
||||
@@ -262,7 +268,7 @@ export async function generateThumbnailForFile(file: File): Promise<string> {
|
||||
const fullArrayBuffer = await file.arrayBuffer();
|
||||
return await generatePDFThumbnail(fullArrayBuffer, scale);
|
||||
} catch (error) {
|
||||
console.warn(`PDF processing failed for ${file.name}:`, error);
|
||||
reportThumbnailFailure(file, error);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -314,7 +320,8 @@ export async function generateThumbnailWithMetadata(
|
||||
pageRotations: result.pageRotations,
|
||||
pageDimensions: result.pageDimensions,
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
reportThumbnailFailure(file, error);
|
||||
return { thumbnail: "", pageCount: 0 };
|
||||
}
|
||||
}
|
||||
@@ -344,7 +351,8 @@ export async function generateThumbnailWithMetadata(
|
||||
pageRotations: result.pageRotations,
|
||||
pageDimensions: result.pageDimensions,
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
reportThumbnailFailure(file, error);
|
||||
return { thumbnail: "", pageCount: 1 };
|
||||
}
|
||||
}
|
||||
@@ -389,7 +397,8 @@ export async function generateThumbnailPairWithMetadata(file: File): Promise<{
|
||||
unrotated: toPublic(pair.unrotated),
|
||||
rotated: toPublic(pair.rotated),
|
||||
};
|
||||
} catch {
|
||||
} catch (error) {
|
||||
reportThumbnailFailure(file, error);
|
||||
return {
|
||||
unrotated: { thumbnail: "", pageCount: 0 },
|
||||
rotated: { thumbnail: "", pageCount: 0 },
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
PixelCompareWorkerResponse,
|
||||
PixelCompareWorkerWarnings,
|
||||
} from "@app/types/compare";
|
||||
import { lossyEncodeOptions } from "@app/utils/canvasImageEncoding";
|
||||
|
||||
declare const self: DedicatedWorkerGlobalScope;
|
||||
|
||||
@@ -155,7 +156,7 @@ const renderPageToBitmap = async (
|
||||
return { imageData, bitmap };
|
||||
};
|
||||
|
||||
const ENCODE_OPTS: ImageEncodeOptions = { type: "image/webp", quality: 0.85 };
|
||||
const ENCODE_QUALITY = 0.85;
|
||||
|
||||
const bitmapToBlob = async (
|
||||
bitmap: ImageBitmap,
|
||||
@@ -168,7 +169,7 @@ const bitmapToBlob = async (
|
||||
if (!ctx) throw new Error(errorStrings.canvasContextUnavailable);
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
return await canvas.convertToBlob(ENCODE_OPTS);
|
||||
return await canvas.convertToBlob(await lossyEncodeOptions(ENCODE_QUALITY));
|
||||
};
|
||||
|
||||
const diffDataToBlob = async (
|
||||
@@ -183,7 +184,7 @@ const diffDataToBlob = async (
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.putImageData(diff, 0, 0);
|
||||
return await canvas.convertToBlob(ENCODE_OPTS);
|
||||
return await canvas.convertToBlob(await lossyEncodeOptions(ENCODE_QUALITY));
|
||||
};
|
||||
|
||||
interface PageTotals {
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
// (Edge / Google Translate / extensions) from crashing the app via
|
||||
// parent-mismatch DOMExceptions. See the module for details.
|
||||
import "@app/utils/patchDomForTranslators";
|
||||
// WebKit is missing several APIs the app assumes (ReadableStream async
|
||||
// iteration, which pdf.js needs for all text extraction; requestIdleCallback).
|
||||
import "@app/utils/engineShims";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/dates/styles.css";
|
||||
import "../vite-env.d.ts"; // oxlint-disable-line no-restricted-imports -- Outside app paths
|
||||
@@ -21,13 +24,8 @@ import { startEagerWasmCompilation } from "@app/services/wasmPrecompiler";
|
||||
applyDevWorktreeLabel();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const scheduleCompilation = () => {
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
requestIdleCallback(() => startEagerWasmCompilation(), { timeout: 2000 });
|
||||
} else {
|
||||
setTimeout(startEagerWasmCompilation, 1000);
|
||||
}
|
||||
};
|
||||
const scheduleCompilation = () =>
|
||||
requestIdleCallback(() => startEagerWasmCompilation(), { timeout: 2000 });
|
||||
|
||||
if (document.readyState === "complete") {
|
||||
scheduleCompilation();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// The shims `src/index.tsx` installs - see core/setupTests.ts.
|
||||
import "@app/utils/engineShims";
|
||||
|
||||
// Mirrors the editor's setup: jsdom lacks a handful of browser APIs that shared
|
||||
// components (Mantine FocusTrap, responsive helpers) touch on render.
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { finishedWithNothingToDeliver } from "@app/components/policies/usePolicyAutoRun";
|
||||
import type { PolicyRunRecord } from "@app/components/policies/policyRunStore";
|
||||
|
||||
/**
|
||||
* A redaction that matches nothing completes with no output file. The import
|
||||
* effect used to skip those runs entirely, so `imported` never flipped and the
|
||||
* file's badge + blocking overlay spun forever - on every engine.
|
||||
*/
|
||||
const run = (overrides: Partial<PolicyRunRecord> = {}): PolicyRunRecord =>
|
||||
({
|
||||
runId: "r",
|
||||
categoryId: "security",
|
||||
fileId: "f",
|
||||
fileName: "f.pdf",
|
||||
fileSize: 1,
|
||||
target: "saas",
|
||||
status: "COMPLETED",
|
||||
outputs: [],
|
||||
error: null,
|
||||
startedAt: 0,
|
||||
...overrides,
|
||||
}) as PolicyRunRecord;
|
||||
|
||||
describe("finishedWithNothingToDeliver", () => {
|
||||
it("settles a completed run that produced no output", () => {
|
||||
expect(finishedWithNothingToDeliver(run())).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a run with outputs to the import path", () => {
|
||||
expect(
|
||||
finishedWithNothingToDeliver(
|
||||
run({ outputs: [{ fileId: "o", fileName: "o.pdf" }] as never }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores runs that aren't finished, or are already settled", () => {
|
||||
expect(finishedWithNothingToDeliver(run({ status: "PENDING" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(finishedWithNothingToDeliver(run({ status: "FAILED" }))).toBe(false);
|
||||
expect(finishedWithNothingToDeliver(run({ imported: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves classification alone - it settles via its own label path", () => {
|
||||
expect(
|
||||
finishedWithNothingToDeliver(run({ categoryId: "classification" })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -124,6 +124,21 @@ function failRun(runId: string, message: string): void {
|
||||
const FILE_WAIT_TRIES = 20;
|
||||
const FILE_WAIT_MS = 250;
|
||||
|
||||
/**
|
||||
* A policy that changed nothing (redaction matched no text, say) completes with no
|
||||
* output: nothing to deliver, but finished. Left unimported, the file's badge and
|
||||
* its blocking overlay spin forever.
|
||||
*/
|
||||
export function finishedWithNothingToDeliver(run: PolicyRunRecord): boolean {
|
||||
return (
|
||||
run.status === "COMPLETED" &&
|
||||
!run.imported &&
|
||||
(run.outputs?.length ?? 0) === 0 &&
|
||||
// Classification has its own settle path: labels, no output file.
|
||||
!isClassificationCategory(run.categoryId)
|
||||
);
|
||||
}
|
||||
|
||||
function isTerminal(status: PolicyRunStatus): boolean {
|
||||
return (
|
||||
status === "COMPLETED" || status === "FAILED" || status === "CANCELLED"
|
||||
@@ -351,13 +366,14 @@ export function usePolicyAutoRun(): void {
|
||||
if (
|
||||
run.status !== "COMPLETED" ||
|
||||
run.imported ||
|
||||
importing.current.has(run.runId) ||
|
||||
// Classification settles even with no outputs (nothing to tag); other
|
||||
// policies need an output to import.
|
||||
(!run.outputs?.length && !classification)
|
||||
importing.current.has(run.runId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (finishedWithNothingToDeliver(run)) {
|
||||
updateRun(run.runId, { imported: true });
|
||||
continue;
|
||||
}
|
||||
importing.current.add(run.runId);
|
||||
// Classification is metadata-only: stamp labels onto the current leaf of
|
||||
// the file it ran on (no version fork). See importClassificationLabels.
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Idle-time scheduling shared by the classification/backfill passes.
|
||||
|
||||
/** Schedule work for the browser's idle time (or soon after, as a fallback). */
|
||||
/**
|
||||
* Schedule work for the browser's idle time. Main thread only: the entry-point
|
||||
* shim guarantees `requestIdleCallback`, but not in worker scope.
|
||||
*/
|
||||
export function scheduleIdle(task: () => void): () => void {
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
const handle = requestIdleCallback(task, { timeout: 2000 });
|
||||
return () => cancelIdleCallback(handle);
|
||||
}
|
||||
const timer = window.setTimeout(task, 200);
|
||||
return () => window.clearTimeout(timer);
|
||||
const handle = requestIdleCallback(task, { timeout: 2000 });
|
||||
return () => cancelIdleCallback(handle);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
import { installFailOnConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// The shims `src/index.tsx` installs - see core/setupTests.ts.
|
||||
import "@app/utils/engineShims";
|
||||
|
||||
installFailOnConsole();
|
||||
|
||||
// Mock localStorage for tests
|
||||
|
||||
@@ -335,6 +335,11 @@ export default defineConfig(async ({ mode, command }) => {
|
||||
compressStaticCopyPlugin(),
|
||||
prerenderOgPlugin(effectiveMode === "saas"),
|
||||
],
|
||||
// Worker bundles are a separate Rollup pass and do NOT inherit `plugins`,
|
||||
// so without this `@app/*` resolves in the app and fails in a worker.
|
||||
worker: {
|
||||
plugins: () => [tsconfigPaths({ projects: [tsconfigProject] })],
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
allowedHosts: allowedHosts.length > 0 ? allowedHosts : undefined,
|
||||
|
||||
Reference in New Issue
Block a user