Skip to main content

TIFF Viewer (react-tiff) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Add native TIFF viewing to the TiffViewerPage using react-tiff, shown when server-side PNG conversion hasn’t run yet (conversionStatus === 'none' or 'failed'). Architecture: react-tiff and its entire CJS dependency tree (utif, react-i18next, i18next, axios) are isolated in a dedicated vendor-tiff Rollup manualChunk and excluded from Vite pre-bundling. TiffViewerPage stays lazy-imported in App.tsx so vendor-tiff never touches the eager boot path. The existing PNG preview path (conversionStatus === 'done') is unchanged. Tech Stack: react-tiff 0.0.17, Vite 6 manualChunks, React lazy/Suspense

Task 1: Install react-tiff

Files:
  • Modify: ui/package.json (dependency added by pnpm)
  • Modify: pnpm-lock.yaml (updated by pnpm)
  • Step 1: Install the package
cd /Users/ssh/Documents/Beta-App/odontoX/ui
pnpm add react-tiff
Expected output: + react-tiff 0.0.17 (or similar) with no errors.
  • Step 2: Verify the dependency tree installed correctly
ls node_modules/react-tiff node_modules/utif node_modules/react-i18next node_modules/i18next node_modules/axios
All five directories must exist.

Task 2: Isolate react-tiff in its own Rollup chunk

Files:
  • Modify: ui/vite.config.ts
  • Step 1: Add vendor-tiff to manualChunks and exclude deps from pre-bundling
Open ui/vite.config.ts. Make two edits: Edit 1 — add exclude to optimizeDeps (currently only has include):
    optimizeDeps: {
      include: ['buffer'],
      exclude: ['react-tiff', 'utif', 'react-i18next', 'i18next'],
    },
Edit 2 — add vendor-tiff line inside manualChunks, after the existing vendor-three line:
            if (id.includes('/three/') || id.includes('/troika-') || id.includes('/webgl-sdf-generator/') || id.includes('/bidi-js/')) return 'vendor-three';
            if (id.includes('/react-tiff/') || id.includes('/utif/') || id.includes('/react-i18next/') || id.includes('/i18next/')) return 'vendor-tiff';
  • Step 2: Verify the config change looks correct
grep -A2 "optimizeDeps" ui/vite.config.ts
grep "vendor-tiff" ui/vite.config.ts
Expected: exclude array contains all four packages; vendor-tiff line is present in manualChunks.

Task 3: Revert TiffViewerPage to lazy import in App.tsx

Files:
  • Modify: ui/src/App.tsx line ~45
TiffViewerPage was made eager as a band-aid in a previous commit. With the vendor-tiff chunk isolating all CJS deps, it must go back to lazy so vendor-tiff never loads during app boot.
  • Step 1: Change the import back to lazy
Find this line in ui/src/App.tsx:
import TiffViewerPage from './pages/TiffViewerPage';
Replace with:
const TiffViewerPage = lazy(() => import('./pages/TiffViewerPage'));
  • Step 2: Verify the change
grep "TiffViewerPage" ui/src/App.tsx
Expected: const TiffViewerPage = lazy(() => import('./pages/TiffViewerPage')); — NOT a bare import.

Task 4: Update TiffViewerPage to use TIFFViewer for raw TIFF files

Files:
  • Modify: ui/src/pages/TiffViewerPage.tsx
Current behaviour:
  • conversionStatus === 'done' → download PNG blob → <img>
  • conversionStatus === 'pending' → spinner + message
  • conversionStatus === 'failed' → error screen (dead end)
  • conversionStatus === 'none' → downloads raw TIFF blob but renders with <img> (broken in Chrome/Firefox)
New behaviour:
  • 'done' → unchanged (PNG <img>)
  • 'pending' → unchanged (spinner)
  • 'none' or 'failed' → download raw TIFF blob → blob URL → <TIFFViewer>
  • Step 1: Rewrite TiffViewerPage.tsx
Replace the entire content of ui/src/pages/TiffViewerPage.tsx with:
import { useMemo, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2, FileImage } from 'lucide-react';
import { TIFFViewer } from 'react-tiff';
import 'react-tiff/dist/index.css';
import { qk } from '@/lib/queryKeys';
import { fetchWithAuth, downloadFileAsBlob } from '@/lib/serverComm';

export default function TiffViewerPage() {
  const fileId = new URLSearchParams(window.location.search).get('id');

  const { data: meta } = useQuery({
    queryKey: [...qk.files.detail(fileId ?? ''), 'meta'],
    queryFn: async () => {
      const res = await fetchWithAuth(`/api/v1/protected/files/${fileId}`);
      return res.json() as Promise<{ conversionStatus: string; previewKey: string | null; fileName: string }>;
    },
    enabled: !!fileId,
    staleTime: 30_000,
  });

  const usePreview = meta?.conversionStatus === 'done';
  const useRawTiff = meta?.conversionStatus === 'none' || meta?.conversionStatus === 'failed';

  const downloadUrl = usePreview
    ? `/api/v1/protected/files/${fileId}/download?preview=true`
    : `/api/v1/protected/files/${fileId}/download`;

  const { data: blob, isLoading, isError } = useQuery({
    queryKey: qk.files.detail(fileId ?? ''),
    queryFn: () => downloadFileAsBlob(downloadUrl),
    enabled: !!fileId && (usePreview || useRawTiff),
    staleTime: 5 * 60 * 1000,
  });

  const blobUrl = useMemo(() => (blob ? URL.createObjectURL(blob) : null), [blob]);
  useEffect(() => () => { if (blobUrl) URL.revokeObjectURL(blobUrl); }, [blobUrl]);

  if (!fileId) {
    return <ErrorScreen message="No file ID provided." />;
  }

  if (meta?.conversionStatus === 'pending') {
    return (
      <div className="min-h-screen bg-background flex flex-col items-center justify-center gap-3 text-muted-foreground">
        <Loader2 className="h-8 w-8 animate-spin" />
        <p className="text-sm">Preparing TIFF preview — this usually takes a few seconds.</p>
        <p className="text-xs opacity-60">Close this tab and reopen once the badge clears.</p>
      </div>
    );
  }

  if (isLoading || !blobUrl) {
    return (
      <div className="min-h-screen bg-background flex items-center justify-center">
        <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
      </div>
    );
  }

  if (isError && !blobUrl) {
    return <ErrorScreen message="Could not load the file. Try closing and reopening this tab." />;
  }

  if (usePreview) {
    return (
      <div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
        <div className="w-full max-w-5xl">
          {meta?.fileName && (
            <div className="flex items-center gap-2 mb-3 text-sm text-muted-foreground">
              <FileImage className="h-4 w-4" />
              <span className="truncate">{meta.fileName}</span>
            </div>
          )}
          <img
            src={blobUrl}
            alt={meta?.fileName ?? 'TIFF preview'}
            className="w-full h-auto rounded-lg shadow-lg"
          />
        </div>
      </div>
    );
  }

  // conversionStatus === 'none' or 'failed' — render raw TIFF with react-tiff
  return (
    <div className="min-h-screen bg-background flex flex-col items-center justify-center p-4">
      <div className="w-full max-w-5xl">
        {meta?.fileName && (
          <div className="flex items-center gap-2 mb-3 text-sm text-muted-foreground">
            <FileImage className="h-4 w-4" />
            <span className="truncate">{meta.fileName}</span>
          </div>
        )}
        <TIFFViewer
          tiff={blobUrl}
          lang="en"
          paginate="ltr"
          zoomable
        />
      </div>
    </div>
  );
}

function ErrorScreen({ message }: { message: string }) {
  return (
    <div className="min-h-screen bg-background flex items-center justify-center text-muted-foreground">
      <p className="text-sm">{message}</p>
    </div>
  );
}
  • Step 2: Check TypeScript compiles cleanly
cd /Users/ssh/Documents/Beta-App/odontoX/ui
npx tsc --noEmit 2>&1 | grep -i "error\|TiffViewer" | head -20
Expected: no errors. If react-tiff has no bundled types, the error will be Could not find a declaration file for module 'react-tiff' — add // @ts-ignore above the import and continue.

Task 5: Build and verify no TDZ crash

  • Step 1: Run the production build
cd /Users/ssh/Documents/Beta-App/odontoX/ui
pnpm run build 2>&1 | tail -20
Expected: build succeeds with a vendor-tiff chunk visible in the output. No errors.
  • Step 2: Confirm vendor-tiff chunk exists in dist
ls dist/assets/ | grep -i "tiff\|vendor" || echo "chunk present under hash name"
The vendor-tiff chunk won’t have “tiff” in its name (all chunks use content hashes). Just confirm the build succeeded without chunk errors.
  • Step 3: Start preview and check for TDZ crash
pkill -f "vite preview" 2>/dev/null; sleep 1
npx vite preview --port 4173 &
sleep 3
Then open Playwright and navigate to http://localhost:4173/ and check for console errors. Zero errors = success. Expected console errors: 0. If there’s a Cannot set properties of undefined (setting 'Activity') or Cannot read createContext error, the vendor-tiff chunk isolation isn’t working — stop and diagnose before continuing.

Task 6: Commit and deploy

  • Step 1: Stage all changes
cd /Users/ssh/Documents/Beta-App/odontoX
git add ui/package.json ui/package-lock.json pnpm-lock.yaml ui/vite.config.ts ui/src/App.tsx ui/src/pages/TiffViewerPage.tsx RELEASES.md
git diff --cached --stat
  • Step 2: Append release notes to RELEASES.md
Add at the TOP of RELEASES.md (before existing entries):
## [2026-05-07] — TIFF viewer: view X-ray files directly without Ruby analysis

### What's new
- X-ray TIFF files in the Bridge Inbox can now be viewed directly in the browser, even before Ruby AI analysis has been run. Previously, opening a TIFF with no server preview showed a broken image.
- Multi-page TIFFs display with page navigation. Zoom controls are available for detailed inspection.

### Fixed
- TIFF files with `conversionStatus: 'none'` (no Ruby analysis yet) or `'failed'` now render correctly using a client-side TIFF decoder instead of showing a blank/broken image.

### Internal / Technical
- Installed `react-tiff` (v0.0.17) and its dependencies (utif, react-i18next, i18next, axios) isolated in a dedicated `vendor-tiff` Rollup chunk to prevent CJS initialisation order crashes on page load.
- TiffViewerPage reverted to lazy import — it opens in a new tab and eager loading gave no benefit while adding the vendor-tiff chunk to the critical boot path.

### Affected areas
- UI: yes — TiffViewerPage (Bridge Inbox TIFF viewer tab)
- Backend: no
- Bridge: no
  • Step 3: Commit
git commit -m "$(cat <<'EOF'
feat(ui): add react-tiff viewer for raw TIFF files (no Ruby analysis needed)

TiffViewerPage now uses TIFFViewer for files with conversionStatus 'none'
or 'failed', falling back to the existing PNG preview path when 'done'.

Isolation: vendor-tiff manualChunk + optimizeDeps.exclude for react-tiff,
utif (CJS), react-i18next, i18next prevents TDZ crashes on app boot.
TiffViewerPage is lazy again so vendor-tiff never loads at startup.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
EOF
)"
  • Step 4: Build and deploy to Cloudflare Pages
cd /Users/ssh/Documents/Beta-App/odontoX/ui
pnpm run build 2>&1 | tail -5
npx wrangler pages deploy dist --project-name odonto-prod-ui --branch main --commit-dirty=true 2>&1 | tail -8
  • Step 5: Force-promote canonical deployment
ACCT=9da8a2bb48668ff798b91bd00e9ae005
TOKEN=$(grep 'oauth_token' ~/Library/Preferences/.wrangler/config/default.toml | sed 's/.*"\(.*\)"/\1/')
PROJECT=odonto-prod-ui

LATEST=$(curl -s "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/$PROJECT" \
  -H "Authorization: Bearer $TOKEN" | \
  python3 -c "import sys,json;print(json.load(sys.stdin)['result']['latest_deployment']['id'])")

curl -s -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$ACCT/pages/projects/$PROJECT/deployments/$LATEST/rollback" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" | \
  python3 -c "
import sys, json; d = json.load(sys.stdin)
if d.get('result'):
    print('Promoted to canonical:', d['result']['id'])
elif 'currently in production' in str(d.get('errors','')):
    print('Already canonical — no action needed')
else:
    print('Promotion failed:', d.get('errors'))
"
  • Step 6: Verify all domains serve the new build
for domain in go.odontox.io portal.odontox.io odontox.io id.odontox.io; do
  echo -n "$domain: "
  curl -s "https://$domain/" -H "Accept: text/html" | grep -o 'src="/assets/[^"]*\.js"' | head -1
done
All four must show the same chunk hash.