From 4e0a6504af27103547d736043b327de10b3fd33e Mon Sep 17 00:00:00 2001 From: cxymds Date: Sun, 14 Jun 2026 21:11:52 +0800 Subject: [PATCH] fix: harden browser page webkit compatibility --- app/(dashboard)/browser/page.tsx | 3 ++- components/buckets/list.tsx | 3 ++- components/events/new-form.tsx | 5 +++-- components/object/info.tsx | 3 ++- components/object/list.tsx | 19 +++++++++++++++++-- components/object/view.tsx | 3 ++- contexts/api-context.tsx | 5 +++-- contexts/s3-context.tsx | 5 +++-- lib/safe-date.ts | 12 ++++++++++++ lib/schedule-microtask.ts | 13 +++++++++++++ tests/lib/browser-safety-source.test.js | 20 ++++++++++++++++++++ tests/lib/object-list-source.test.js | 19 +++++++++++++++++++ 12 files changed, 98 insertions(+), 12 deletions(-) create mode 100644 lib/safe-date.ts create mode 100644 lib/schedule-microtask.ts create mode 100644 tests/lib/browser-safety-source.test.js create mode 100644 tests/lib/object-list-source.test.js diff --git a/app/(dashboard)/browser/page.tsx b/app/(dashboard)/browser/page.tsx index cd1a52cb..98854699 100644 --- a/app/(dashboard)/browser/page.tsx +++ b/app/(dashboard)/browser/page.tsx @@ -20,6 +20,7 @@ import { usePermissions } from "@/hooks/use-permissions" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" import { niceBytes } from "@/lib/functions" +import { normalizeDateToIso } from "@/lib/safe-date" import { BrowserContent } from "./content" import type { ColumnDef } from "@tanstack/react-table" import dayjs from "dayjs" @@ -154,7 +155,7 @@ function BrowserBucketsPage() { const bucketRow: BucketRow = { Name: name, - CreationDate: item?.CreationDate ? new Date(item.CreationDate).toISOString() : "", + CreationDate: normalizeDateToIso(item?.CreationDate), } return bucketRow diff --git a/components/buckets/list.tsx b/components/buckets/list.tsx index 4cf2b878..51e1ff20 100644 --- a/components/buckets/list.tsx +++ b/components/buckets/list.tsx @@ -12,6 +12,7 @@ import { useBucket } from "@/hooks/use-bucket" import { useSystem } from "@/hooks/use-system" import { Spinner } from "@/components/ui/spinner" import { niceBytes } from "@/lib/functions" +import { normalizeDateToIso } from "@/lib/safe-date" import type { ColumnDef } from "@tanstack/react-table" import dayjs from "dayjs" @@ -132,7 +133,7 @@ export function BucketList({ title, emptyDescription, getBucketHref }: BucketLis return { Name: name, - CreationDate: item?.CreationDate ? new Date(item.CreationDate).toISOString() : "", + CreationDate: normalizeDateToIso(item?.CreationDate), } }) .filter((bucket): bucket is BucketListRow => bucket !== null) diff --git a/components/events/new-form.tsx b/components/events/new-form.tsx index 222f1915..b17971ff 100644 --- a/components/events/new-form.tsx +++ b/components/events/new-form.tsx @@ -13,6 +13,7 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { useBucket } from "@/hooks/use-bucket" import { useEventTarget } from "@/hooks/use-event-target" import { useMessage } from "@/lib/feedback/message" +import { scheduleMicrotask } from "@/lib/schedule-microtask" interface EventsNewFormProps { open: boolean @@ -67,7 +68,7 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess, disab }, [getEventTargetArnList]) useEffect(() => { - queueMicrotask(() => loadArnList()) + scheduleMicrotask(() => loadArnList()) }, [loadArnList]) const resetForm = useCallback(() => { @@ -81,7 +82,7 @@ export function EventsNewForm({ open, onOpenChange, bucketName, onSuccess, disab useEffect(() => { if (open) { - queueMicrotask(() => resetForm()) + scheduleMicrotask(() => resetForm()) } }, [open, resetForm]) diff --git a/components/object/info.tsx b/components/object/info.tsx index e8e18d00..03b08579 100644 --- a/components/object/info.tsx +++ b/components/object/info.tsx @@ -22,6 +22,7 @@ import { useMessage } from "@/lib/feedback/message" import { useDialog } from "@/lib/feedback/dialog" import { exportFile } from "@/lib/export-file" import { getContentType } from "@/lib/mime-types" +import { normalizeDateToIso } from "@/lib/safe-date" import { getDefaultObjectRetentionDate, getMinimumObjectRetentionDate, @@ -431,7 +432,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie } } - const lastModified = object?.LastModified ? new Date(object.LastModified as string | Date).toISOString() : "" + const lastModified = normalizeDateToIso((object?.LastModified as string | Date | undefined) ?? undefined) return ( <> diff --git a/components/object/list.tsx b/components/object/list.tsx index 9e344be9..704dc42f 100644 --- a/components/object/list.tsx +++ b/components/object/list.tsx @@ -40,6 +40,7 @@ import { useMessage } from "@/lib/feedback/message" import { exportFile } from "@/lib/export-file" import { getContentType } from "@/lib/mime-types" import { formatBytes } from "@/lib/functions" +import { normalizeDateToIso } from "@/lib/safe-date" import { buildBucketPath } from "@/lib/bucket-path" import { createObjectListScope, @@ -189,10 +190,24 @@ export function ObjectList({ Key: item.Key ?? "", type: "object" as const, Size: item.Size ?? 0, - LastModified: item.LastModified ? item.LastModified.toISOString() : "", + LastModified: normalizeDateToIso(item.LastModified), })) setData([...prefixItems, ...objectItems]) + } catch (error) { + console.error("Failed to fetch objects:", error) + message.error((error as Error)?.message ?? t("Failed to load objects")) + if ( + shouldApplyObjectListResponse({ + requestId, + activeRequestId: requestIdRef.current, + requestScope, + activeScope: activeScopeRef.current, + }) + ) { + setNextToken(undefined) + setData([]) + } } finally { window.setTimeout(() => { if ( @@ -208,7 +223,7 @@ export function ObjectList({ }, 200) } }, - [bucket, prefix, resolvedPageSize, continuationToken, showDeleted, listObject], + [bucket, prefix, resolvedPageSize, continuationToken, showDeleted, listObject, message, t], ) const prevRefreshTriggerRef = React.useRef(refreshTrigger) diff --git a/components/object/view.tsx b/components/object/view.tsx index 716d9407..8f2e4cb6 100644 --- a/components/object/view.tsx +++ b/components/object/view.tsx @@ -9,6 +9,7 @@ import { useObject } from "@/hooks/use-object" import { useMessage } from "@/lib/feedback/message" import { exportFile } from "@/lib/export-file" import { getContentType } from "@/lib/mime-types" +import { normalizeDateToIso } from "@/lib/safe-date" interface ObjectViewProps { bucketName: string @@ -49,7 +50,7 @@ export function ObjectView({ bucketName, objectKey }: ObjectViewProps) { } } - const lastModified = object?.LastModified ? new Date(object.LastModified as string).toISOString() : "" + const lastModified = normalizeDateToIso((object?.LastModified as string | Date | undefined) ?? undefined) return (
diff --git a/contexts/api-context.tsx b/contexts/api-context.tsx index ddb0a2a2..e112428d 100644 --- a/contexts/api-context.tsx +++ b/contexts/api-context.tsx @@ -7,6 +7,7 @@ import { AwsClient } from "@/lib/aws4fetch" import { ApiErrorHandler } from "@/lib/api-error-handler" import { useAuth } from "@/contexts/auth-context" import { configManager } from "@/lib/config" +import { scheduleMicrotask } from "@/lib/schedule-microtask" interface ApiContextValue { api: ApiClient | null @@ -24,14 +25,14 @@ export function ApiProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (!isAuthenticated || !credentials?.AccessKeyId) { - queueMicrotask(() => { + scheduleMicrotask(() => { setApiClient(null) setIsReady(true) }) return } - queueMicrotask(() => setIsReady(false)) + scheduleMicrotask(() => setIsReady(false)) let cancelled = false configManager.loadConfig().then((siteConfig) => { diff --git a/contexts/s3-context.tsx b/contexts/s3-context.tsx index 91060b08..e7d36bd4 100644 --- a/contexts/s3-context.tsx +++ b/contexts/s3-context.tsx @@ -7,6 +7,7 @@ import { useAuth } from "@/contexts/auth-context" import { addApiPrefixMiddleware } from "@/lib/api-prefix-middleware" import { configManager } from "@/lib/config" import { getServiceErrorMessage, getXmlErrorMessage } from "@/lib/error-handler" +import { scheduleMicrotask } from "@/lib/schedule-microtask" import type { SiteConfig } from "@/types/config" interface S3Response { @@ -79,14 +80,14 @@ export function S3Provider({ children }: { children: React.ReactNode }) { useEffect(() => { if (!isAuthenticated || !credentials?.AccessKeyId) { - queueMicrotask(() => { + scheduleMicrotask(() => { setS3Client(null) setIsReady(true) }) return } - queueMicrotask(() => setIsReady(false)) + scheduleMicrotask(() => setIsReady(false)) let cancelled = false configManager.loadConfig().then((siteConfig: SiteConfig) => { diff --git a/lib/safe-date.ts b/lib/safe-date.ts new file mode 100644 index 00000000..a1d18577 --- /dev/null +++ b/lib/safe-date.ts @@ -0,0 +1,12 @@ +export function normalizeDateToIso(value: Date | string | undefined | null): string { + if (!value) return "" + + const date = value instanceof Date ? value : new Date(value) + const time = date.getTime() + + if (Number.isNaN(time)) { + return "" + } + + return date.toISOString() +} diff --git a/lib/schedule-microtask.ts b/lib/schedule-microtask.ts new file mode 100644 index 00000000..8cfa20f5 --- /dev/null +++ b/lib/schedule-microtask.ts @@ -0,0 +1,13 @@ +export function scheduleMicrotask(callback: () => void) { + if (typeof queueMicrotask === "function") { + queueMicrotask(callback) + return + } + + if (typeof Promise === "function") { + void Promise.resolve().then(callback) + return + } + + globalThis.setTimeout(callback, 0) +} diff --git a/tests/lib/browser-safety-source.test.js b/tests/lib/browser-safety-source.test.js new file mode 100644 index 00000000..7c21626b --- /dev/null +++ b/tests/lib/browser-safety-source.test.js @@ -0,0 +1,20 @@ +import test from "node:test" +import assert from "node:assert/strict" +import fs from "node:fs" + +test("safe date helper returns an empty string for invalid values instead of throwing", () => { + const source = fs.readFileSync("lib/safe-date.ts", "utf8") + + assert.match(source, /if \(!value\) return ""/) + assert.match(source, /const date = value instanceof Date \? value : new Date\(value\)/) + assert.match(source, /if \(Number\.isNaN\(time\)\) \{\s+return ""\s+\}/) + assert.match(source, /return date\.toISOString\(\)/) +}) + +test("scheduleMicrotask falls back when queueMicrotask is unavailable", () => { + const source = fs.readFileSync("lib/schedule-microtask.ts", "utf8") + + assert.equal(source.includes('if (typeof queueMicrotask === "function")'), true) + assert.equal(source.includes('if (typeof Promise === "function")'), true) + assert.equal(source.includes("globalThis.setTimeout(callback, 0)"), true) +}) diff --git a/tests/lib/object-list-source.test.js b/tests/lib/object-list-source.test.js new file mode 100644 index 00000000..e6aba2ba --- /dev/null +++ b/tests/lib/object-list-source.test.js @@ -0,0 +1,19 @@ +import test from "node:test" +import assert from "node:assert/strict" +import fs from "node:fs" + +test("object list normalizes LastModified through the safe date helper", () => { + const source = fs.readFileSync("components/object/list.tsx", "utf8") + + assert.equal(source.includes('import { normalizeDateToIso } from "@/lib/safe-date"'), true) + assert.equal(source.includes("LastModified: normalizeDateToIso(item.LastModified)"), true) + assert.equal(source.includes('item.LastModified ? item.LastModified.toISOString() : ""'), false) +}) + +test("object list falls back to an empty table instead of crashing the page on fetch errors", () => { + const source = fs.readFileSync("components/object/list.tsx", "utf8") + + assert.equal(source.includes('console.error("Failed to fetch objects:", error)'), true) + assert.equal(source.includes('message.error((error as Error)?.message ?? t("Failed to load objects"))'), true) + assert.equal(source.includes("setData([])"), true) +})