Skip to content

Specification: Phase 3.6 — SPA Integration

Wire the design system image components to the production backend through TanStack Query hooks (FD-01 typed provider pattern). All new code follows FD-02 hybrid structure. Entity persist stays in Redux (FD-05).

Follow the typescript-coding skill for TypeScript conventions, the ui-component skill for component wiring patterns, and the unit-tests-frontend skill for Jest + React Testing Library test conventions.

  • Phase 3.4 complete: @arda-cards/design-system published with lifecycle types and updated components
  • Phase 3.5 complete: BFF routes implemented and tested
  • @arda-cards/api-proxy published (Phase 3.1)
  • Phase 3.0 legacy cleanup committed

Source: SPA specification, upload-component-backend-analysis.md, goal.md Phase 4

  • Add @tanstack/react-query and @tanstack/react-query-devtools to package.json
  • Bump @arda-cards/design-system to the most recent published version (from Phase 3.4)
  • Bump @arda-cards/api-proxy to the most recent published version (from Phase 3.1). Note: api-proxy is a BFF-only dependency — it is used by src/server/ routes, never by SPA code (constraint 12).
  • Create src/providers/query-provider.tsx with QueryClient configuration:
    • staleTime: 5min
    • retry: 1
    • refetchOnWindowFocus: false
  • Add QueryClientProvider to the app provider stack in layout.tsx, alongside the existing ReduxProvider and AuthProvider
  • Full checks pass after this task before proceeding

T-2: SPA API Functions Layer (src/api/image-upload.ts)

Section titled “T-2: SPA API Functions Layer (src/api/image-upload.ts)”

New architectural layer (FD-02). Plain async functions calling BFF routes via fetch(). No TanStack imports. No api-proxy imports — that package is BFF-only (constraint 12).

Type ownership: Request and response types (ImageUploadRequest, ImageUploadResponse) are re-exported from @arda-cards/api-proxy/reference/item through a shared types barrel. The SPA API layer imports these types for compile-time validation against the Backend contract. Since api-proxy is listed as a dependency of arda-frontend-app (for BFF usage), re-exporting the types from a shared barrel does not introduce a new dependency.

Rate limit error handling: The BFF returns machine-readable error codes for rate limit rejections (e.g., { code: "RATE_LIMITED", retryAfterMs }) rather than user-facing text. The SPA API layer is responsible for translating these codes into user-facing error messages appropriate for display in the UI (e.g., in ImageDropZone).

Functions:

  • getImageUploadUrl(request: ImageUploadUrlRequest): Promise<ImageUploadUrlResponse> — POST /api/image-upload
  • uploadToS3(uploadUrl, formFields, imageBlob, onProgress?): Promise<void> — XHR with upload.onprogress for future progress support
  • checkReachability(url: string): Promise<boolean> — HEAD direct, BFF fallback on CORS error
  • fetchExternalImage(url: string): Promise<Blob> — POST /api/storage/fetch-url
  • refreshCdnCookies(): Promise<void> — POST /api/storage/cdn-cookies

Unit tests for each function with mocked fetch.

T-3: TanStack Mutation Hooks (src/hooks/image-upload/)

Section titled “T-3: TanStack Mutation Hooks (src/hooks/image-upload/)”

FD-01 typed provider implementations backed by TanStack Query.

  • useImageUpload() — mutation that orchestrates: getImageUploadUrluploadToS3 → return cdnUrl. Always JPEG contentType (SPA-FR-021). Invalidates ['items'] on success. Single upload enforcement (SPA-FR-020): mutation is disabled while another is pending.
  • useCheckReachability() — mutation wrapping checkReachability()
  • useFetchExternalImage() — mutation wrapping fetchExternalImage()
  • Query key factory: imageKeys object

Unit tests for each hook using @testing-library/react-hooks with a QueryClientProvider wrapper.

Section titled “T-4: CDN Cookie Query Hook (src/hooks/cdn/)”
  • useCdnCookies(enabled?: boolean) — query with:
    • refetchInterval: derived from the shared AwsNaming.cookieRefreshIntervalMs constant (default ~15 min = 50% of 30-min cookie TTL). The value is obtained via resolveAwsNaming().cookieRefreshIntervalMs, ensuring the SPA refresh interval stays coupled to the server-side cookie TTL configuration.
    • refetchIntervalInBackground: true
    • staleTime: cookieRefreshIntervalMs * 0.67 (approximately 2/3 of the refresh interval), obtained from the same resolveAwsNaming() call.
    • retry: 2
  • useRefreshCdnCookies() — returns a function that invalidates ['image', 'cdn-cookies'] for imperative refresh (tenant switch, 403 recovery)

Unit tests: refetchInterval configured; imperative refresh triggers invalidation.

Section titled “T-5: CDN Cookie Provider (src/providers/cdn-cookie-provider.tsx)”
  • React Context providing { isReady: boolean, refreshNow: () => void }
  • Uses useCdnCookies() and useRefreshCdnCookies() internally
  • Added to the provider stack at app level
  • Unit test: context values are accessible by children

T-6: Wiring Hooks (src/hooks/image-upload/use-item-image-upload-dialog.ts)

Section titled “T-6: Wiring Hooks (src/hooks/image-upload/use-item-image-upload-dialog.ts)”

Bridge hooks that connect TanStack mutations to component callback prop signatures.

  • useItemImageUploadDialog() — returns: { handleUpload, handleCheckReachability, isUploading, uploadError, resetUpload } matching the ImageUploadDialog onUpload/onCheckReachability prop signatures
  • handleUpload: (blob: Blob) => Promise<string> — wraps useImageUpload().mutateAsync()
  • handleCheckReachability: (url: string) => Promise<boolean> — wraps useCheckReachability().mutateAsync()

T-7: ITEM_IMAGE_CONFIG (src/constants/item-image-config.ts)

Section titled “T-7: ITEM_IMAGE_CONFIG (src/constants/item-image-config.ts)”
  • ITEM_IMAGE_CONFIG: ImageFieldConfig with:
    • aspectRatio: 1:1
    • acceptedFormats: [jpeg, png, webp, heic, heif]
    • maxSizeBytes: 10 MB
    • minDimensionPx: 200
    • entityTypeDisplayName: "Item"
    • propertyDisplayName: "Product Image"

T-7b: Shared CDN Image Recovery Hook (src/hooks/cdn/use-image-with-cdn-recovery.ts)

Section titled “T-7b: Shared CDN Image Recovery Hook (src/hooks/cdn/use-image-with-cdn-recovery.ts)”

Shared hook providing 403 recovery for all image display contexts (grid, form, inspector). Keeps ImageDisplay pure (FD-01) while ensuring consistent recovery behavior across the app.

interface CdnImageState {
/** Current image src — includes a cache-bust key after recovery. */
src: string | null;
/** True while a cookie refresh is in progress after a 403. */
isRecovering: boolean;
/** Called by ImageDisplay's onError prop. */
handleError: () => void;
}
function useImageWithCdnRecovery(
cdnUrl: string | null,
options?: { maxRetries?: number },
): CdnImageState;

Behavior:

  1. Returns cdnUrl as src on initial render.
  2. When handleError is called (image failed to load):
    • If the URL is a CDN URL: calls refreshNow() from CdnCookieContext, sets isRecovering: true, then updates src with a cache-bust query param (?_r=<timestamp>) to force the browser to re-request the image with fresh cookies. The CDN host is derived from NEXT_PUBLIC_INFRASTRUCTURE and NEXT_PUBLIC_PARTITION environment variables using the same naming convention as the server-side resolveAwsNaming() utility: ${partition.toLowerCase()}.${infrastructure.toLowerCase()}.assets.arda.cards. The hook checks new URL(cdnUrl).hostname === derivedCdnHost. This is computed once at module load time and reused across all hook instances.
    • If the URL is not a CDN URL: does nothing (the error is not cookie-related).
  3. Maximum one retry per URL per mount (configurable via maxRetries, default 1). After exhausting retries, stops recovering — the error state in ImageDisplay persists.
  4. Resets retry count when cdnUrl changes (new image).

Unit tests:

  • CDN URL error triggers refreshNow() and updates src
  • Non-CDN URL error does not trigger refresh
  • Maximum retries respected — no infinite refresh loop
  • isRecovering is true during refresh, false after
  • New cdnUrl resets retry count

Modify files under src/components/items/ in place:

  • Import ImageCellDisplay from @arda-cards/design-system/canary
  • Import createImageCellEditor factory from the design system
  • Update item grid column definitions:
    • Image column cellRenderer: ImageCellDisplay, with cellRendererParams: { config: ITEM_IMAGE_CONFIG }
    • Image column cellEditor: createImageCellEditor({ imageConfig: ITEM_IMAGE_CONFIG, useImageUpload: ..., useCheckReachability: ... })
    • Image column editable: true
  • Import ImageHoverPreview for the hover popover (~500 ms delay)
  • Wire useImageWithCdnRecovery (T-7b) into the grid image cell renderer to handle 403 recovery. The hook’s handleError is passed as the onError prop to ImageCellDisplay. The grid wrapper batches recovery across multiple cells via TanStack Query’s deduplication of the cookie refresh query.

Modify src/components/items/ItemFormPanel.tsx in place:

  • Import ImageFormField from @arda-cards/design-system/canary
  • Import useItemImageUploadDialog wiring hook
  • Replace the disabled dropzone placeholder with ImageFormField wired to the upload dialog
  • Add Item form: optional image field using ITEM_IMAGE_CONFIG
  • Edit Item form: support change and remove; remove sends imageUrl: null via the existing Redux updateItem() thunk (FD-05)
  • Entity persist after upload: onConfirm receives ImageUploadResult, sets form.imageUrl = result.imageUrl; form submission uses the existing Redux thunk
  • Wire useImageWithCdnRecovery (T-7b) into ImageFormField’s image display to handle 403 recovery in form contexts. The hook’s src and handleError are passed through to the underlying ImageDisplay.
  • Hook tests with QueryClientProvider wrapper in test-utils
  • API function tests with mocked fetch
  • Wiring hook tests
  • Form integration tests (component render with mocked hooks)
  • Grid integration tests (column definition verification)

Update session log / byproducts in the documentation worktree.

Terminal window
# In the documentation worktree
make pr-checks

Commit documentation changes referencing Phase 3.6.

  • All tasks complete with passing unit tests
  • Full local checks pass:
    Terminal window
    # arda-frontend-app
    npm run lint
    npx tsc --noEmit
    npx jest --no-coverage --watchAll=false --forceExit
    npm run build
    Terminal window
    # documentation
    make pr-checks
  • Image upload flow works end-to-end in mock mode (npm run dev:mock)
  • No regressions in existing tests
  • Documentation worktree: changes committed

STOP: Review SPA integration before proceeding to Phase 3.7.

#QuestionOptionsRecommendationDecision
1403 recovery wiring: component-level vs. grid-levelSee trade-off analysis belowB — grid wrapperAgreed
2QueryClient in test-utilsA: shared wrapper in test-utils; B: per-test QueryClientA — shared wrapper is consistentAgreed

When a CDN image returns 403 (expired cookie), the app needs to refresh cookies and retry the image load. The question is where this logic lives.

Option A — Component-level (ImageDisplay calls refreshNow()):

  • ImageDisplay (design system) imports and uses useCdnCookieContext() internally. On <img> onError with 403, it calls refreshNow(), waits, then retries by changing the <img> key.
  • Pro: Self-contained — every ImageDisplay anywhere in the app handles 403 automatically. Works in grids, forms, detail panels, cards.
  • Con: ImageDisplay gains a dependency on CdnCookieContext — a React Context defined in arda-frontend-app. This violates FD-01: design system components must not depend on app-specific contexts or TanStack Query. The component would no longer be portable.
  • Con: The onError event on <img> does not provide the HTTP status code — you only know the image failed, not whether it was a 403 vs. a genuine 404 or network error. The component would have to assume any error might be a stale cookie and try refreshing, which could cause unnecessary refresh storms.
  • Con: Multiple ImageDisplay instances failing simultaneously (e.g., a grid with 20 thumbnails) would each trigger refreshNow() independently, though the cookie query deduplication in TanStack handles this.

Option B — Grid wrapper handles 403 and retries (decided):

  • The grid integration code in arda-frontend-app (T-8) wraps image rendering with 403 awareness. On image error, the grid-level wrapper calls refreshNow() from CdnCookieContext, then triggers a re-render of affected image cells.
  • Pro: ImageDisplay stays pure (FD-01 compliant) — no context dependency, no retry logic, no awareness of CDN cookies. It renders what it’s given and reports errors via onError prop.
  • Pro: The grid wrapper can batch the retry — one cookie refresh for all failed images, then re-render all affected rows.
  • Pro: The wrapper has more context — it knows the error came from a CDN URL (vs. an external URL or a data URI), so it can decide whether cookie refresh is the right action.
  • Mitigated: 403 recovery for non-grid contexts (ImageFormField, ImageInspectorOverlay) is handled by the shared useImageWithCdnRecovery hook (T-7b). All image display contexts in the app use this hook, providing consistent recovery behavior without violating FD-01.
  • Con: Slightly more wiring code in the app (a shared hook that intercepts ImageDisplay’s onError).

Copyright: (c) Arda Systems 2025-2026, All rights reserved