Skip to content

Native text file viewer#2099

Open
mfts wants to merge 3 commits intomainfrom
cursor/native-text-file-viewer-0e5c
Open

Native text file viewer#2099
mfts wants to merge 3 commits intomainfrom
cursor/native-text-file-viewer-0e5c

Conversation

@mfts
Copy link
Owner

@mfts mfts commented Mar 5, 2026

Adds a native text file viewer with client-side pagination and canvas rendering for enhanced security and consistent display.

The viewer renders text files on an HTML5 canvas, preventing text selection and copying. It uses a fixed 80x52 character grid with word-wrapping for consistent page breaks across devices. All pagination and rendering occur client-side, with the raw .txt file stored as-is, and text/plain files are now correctly identified as "text" type, bypassing server-side document conversion.


Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features
    • Added support for viewing and previewing plain text documents with automatic pagination.
    • Text files now render as paginated pages with keyboard navigation (arrow keys) and page counters.
    • Text viewer integrates with existing security features including screenshot protection, watermarks, and activity tracking.
    • Expanded supported document types to include text files across the system.

cursoragent and others added 3 commits March 4, 2026 11:06
- Map text/plain to new 'text' type instead of 'docs' to skip LibreOffice conversion
- Add 'text' to SUPPORTED_DOCUMENT_SIMPLE_TYPES

Co-authored-by: marcftone <marcftone@gmail.com>
- Render text pages on HTML5 Canvas for consistent, non-selectable display
- Deterministic pagination: 80 chars/line, 52 lines/page, word-wrapped
- Keyboard nav (arrow keys), zoom controls, screenshot protection
- Page view tracking via useSafePageViewTracker
- Route fileType 'text' to TextViewer in view-data.tsx

Co-authored-by: marcftone <marcftone@gmail.com>
- Return file URL for 'text' type in views API (file resolution + response)
- Add text type handling in preview-data API endpoint
- Add PreviewTextViewer component for dashboard preview
- Route text files in preview-viewer.tsx
- Add FileTextIcon for 'text' type in get-file-icon.tsx

Co-authored-by: marcftone <marcftone@gmail.com>
@cursor
Copy link

cursor bot commented Mar 5, 2026

Cursor Agent can help with this pull request. Just @cursor in comments and I'll start working on changes in this branch.
Learn more about Cursor Agents

@vercel
Copy link

vercel bot commented Mar 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
papermark Ready Ready Preview, Comment Mar 5, 2026 3:47am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 5, 2026

Walkthrough

This PR adds support for plain text file type throughout the application. Changes include creating text pagination utilities, implementing text viewer components for both preview and full view modes, extending API routes to handle text document versions, updating type mappings and constants, and adding text file icon support.

Changes

Cohort / File(s) Summary
Text Pagination & Utilities
lib/utils/text-pagination.ts
Introduces deterministic text pagination logic with configurable page and line dimensions, including tab expansion and word-wrapping capabilities.
Text Viewers
components/documents/preview-viewers/preview-text-viewer.tsx, components/view/viewer/text-viewer.tsx, components/documents/preview-viewers/preview-viewer.tsx, components/view/view-data.tsx
Adds two text viewer components for preview and full view contexts with canvas-based pagination, keyboard navigation, page tracking, watermark/branding support, and screenshot protection.
API Routes & Preview Data
app/api/views/route.ts, pages/api/teams/[teamId]/documents/[id]/preview-data.ts
Extends file retrieval and response construction to handle text document versions alongside existing document types.
Type Mappings & Constants
lib/constants.ts, lib/utils/get-content-type.ts, lib/utils/get-file-icon.tsx
Adds "text" to supported document types, remaps document content types (doc, rtf) to "text" classification, and adds FileTextIcon support for text mime types.

Possibly Related PRs

  • feat: link as a file #1950: Extends app/api/views/route.ts to treat additional documentVersion types as returnable, directly modifying the same file and code path as this PR.
  • feat: add document preview #1769: Adds plain-text support with modifications to preview infrastructure components and preview-data API handling that align with this PR's structure.
  • feat: add rtf / txt file support #1793: Adds RTF/plain-text support with overlapping changes to document type constants and content type mapping utilities modified in this PR.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: introducing a native text file viewer component with client-side canvas rendering and pagination.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/utils/get-content-type.ts (1)

12-18: ⚠️ Potential issue | 🔴 Critical

Do not classify .doc/.docx/.odt/.rtf as text.
Line 12-Line 17 now routes binary/structured document types to "text". That skips docs conversion (lib/api/documents/process-document.ts, Line 196) and sends incompatible files into the text viewer path (components/view/view-data.tsx, Line 187).

🐛 Proposed fix
-    case "application/msword":
-    case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
-    case "application/vnd.oasis.opendocument.text":
-    case "application/rtf":
-    case "text/rtf":
-    case "text/plain":
-      return "text";
+    case "application/msword":
+    case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
+    case "application/vnd.oasis.opendocument.text":
+    case "application/rtf":
+    case "text/rtf":
+      return "docs";
+    case "text/plain":
+      return "text";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/utils/get-content-type.ts` around lines 12 - 18, The MIME-to-type mapping
in get-content-type.ts incorrectly classifies binary/structured document MIME
types as "text"; update the switch in the getContentType (or equivalent)
function so that application/msword,
application/vnd.openxmlformats-officedocument.wordprocessingml.document,
application/vnd.oasis.opendocument.text, application/rtf and text/rtf are not
returned as "text" but instead returned as "document" (keeping text/plain as
"text"); this will route those files into the document conversion flow
(process-document.ts) and the correct viewer path (view-data.tsx).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@components/documents/preview-viewers/preview-text-viewer.tsx`:
- Around line 178-196: The icon-only pagination Buttons for navigating pages
(the Button components that call goToPreviousPage and goToNextPage and are
disabled based on currentPage/numPages) lack accessible names; add appropriate
aria-label attributes (e.g., aria-label="Previous page" and aria-label="Next
page") or aria-labelledby that include current/total context as needed so screen
readers can announce their purpose, ensuring the labels are present on the
Button elements that wrap ChevronLeftIcon and ChevronRightIcon.
- Around line 64-87: When a new file is loaded the effect's fetchText function
doesn't clear prior viewer state, so stale error/pages/currentPage can leak;
inside the useEffect before starting the async fetch (in the fetchText start or
immediately when file changes) call setError(null), setPages([]) and
setCurrentPage(0) and then setLoading(true) so the viewer is reset for the new
file; update the fetchText/useEffect block (references: useEffect, fetchText,
setError, setPages, setCurrentPage, setLoading, paginateText, file) to perform
these resets before awaiting fetch and keep the existing cancelled checks.

In `@components/view/viewer/text-viewer.tsx`:
- Around line 145-162: Before starting the fetch in the text loading routine,
clear any previous error so a past failure doesn't block future successful
loads; specifically, call setError(null) (or setError("") depending on state
type) right after setLoading(true) and before awaiting fetch in the async loader
that uses setLoading, fetch(file), paginateText, setPages and setPageNumber,
ensuring the cancelled check remains in place.
- Around line 331-346: The useEffect that defines removeQueryParams reads
router.query on mount before Next.js hydration, so add a guard at the top: if
(!router.isReady) return; then perform the existing logic; ensure the effect
depends on router.isReady and dataroomId (and router.query/router.asPath or
router as needed) instead of an empty array so it reruns when hydration
completes; keep the helper removeQueryParams and the router.replace call as-is
but trigger only after router.isReady and dataroomId changes are evaluated.

In `@lib/utils/text-pagination.ts`:
- Around line 74-76: The paginateText function currently reads options into
charsPerLine, linesPerPage and tabSize without validation, which allows
charsPerLine <= 0 or linesPerPage <= 0 and can cause non-terminating loops; fix
by validating and normalizing those values after they are assigned (in the block
that sets charsPerLine, linesPerPage, tabSize) — coerce any non-finite or <=0
charsPerLine and linesPerPage to the corresponding TEXT_PAGE_DEFAULTS values (or
throw a clear ArgumentError) and ensure tabSize is a positive integer as well so
the pagination loop in paginateText always advances.

---

Outside diff comments:
In `@lib/utils/get-content-type.ts`:
- Around line 12-18: The MIME-to-type mapping in get-content-type.ts incorrectly
classifies binary/structured document MIME types as "text"; update the switch in
the getContentType (or equivalent) function so that application/msword,
application/vnd.openxmlformats-officedocument.wordprocessingml.document,
application/vnd.oasis.opendocument.text, application/rtf and text/rtf are not
returned as "text" but instead returned as "document" (keeping text/plain as
"text"); this will route those files into the document conversion flow
(process-document.ts) and the correct viewer path (view-data.tsx).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8d5729cb-7648-4168-8054-a970cc14c22b

📥 Commits

Reviewing files that changed from the base of the PR and between 7e3dc31 and ffdbe56.

📒 Files selected for processing (10)
  • app/api/views/route.ts
  • components/documents/preview-viewers/preview-text-viewer.tsx
  • components/documents/preview-viewers/preview-viewer.tsx
  • components/view/view-data.tsx
  • components/view/viewer/text-viewer.tsx
  • lib/constants.ts
  • lib/utils/get-content-type.ts
  • lib/utils/get-file-icon.tsx
  • lib/utils/text-pagination.ts
  • pages/api/teams/[teamId]/documents/[id]/preview-data.ts

Comment on lines +64 to +87
useEffect(() => {
if (!file) return;
let cancelled = false;
async function fetchText() {
try {
setLoading(true);
const response = await fetch(file!);
if (!response.ok) throw new Error("Failed to load text file");
const text = await response.text();
if (cancelled) return;
setPages(paginateText(text));
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load file");
}
} finally {
if (!cancelled) setLoading(false);
}
}
fetchText();
return () => {
cancelled = true;
};
}, [file]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reset viewer state before loading a new file.
Line 64-Line 87 does not clear previous error/pages or reset currentPage, so stale state can leak across file changes.

💡 Proposed fix
   useEffect(() => {
     if (!file) return;
     let cancelled = false;
     async function fetchText() {
       try {
+        setError(null);
+        setPages(null);
+        setCurrentPage(1);
         setLoading(true);
         const response = await fetch(file!);
         if (!response.ok) throw new Error("Failed to load text file");
         const text = await response.text();
         if (cancelled) return;
         setPages(paginateText(text));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (!file) return;
let cancelled = false;
async function fetchText() {
try {
setLoading(true);
const response = await fetch(file!);
if (!response.ok) throw new Error("Failed to load text file");
const text = await response.text();
if (cancelled) return;
setPages(paginateText(text));
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load file");
}
} finally {
if (!cancelled) setLoading(false);
}
}
fetchText();
return () => {
cancelled = true;
};
}, [file]);
useEffect(() => {
if (!file) return;
let cancelled = false;
async function fetchText() {
try {
setError(null);
setPages(null);
setCurrentPage(1);
setLoading(true);
const response = await fetch(file!);
if (!response.ok) throw new Error("Failed to load text file");
const text = await response.text();
if (cancelled) return;
setPages(paginateText(text));
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load file");
}
} finally {
if (!cancelled) setLoading(false);
}
}
fetchText();
return () => {
cancelled = true;
};
}, [file]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/documents/preview-viewers/preview-text-viewer.tsx` around lines 64
- 87, When a new file is loaded the effect's fetchText function doesn't clear
prior viewer state, so stale error/pages/currentPage can leak; inside the
useEffect before starting the async fetch (in the fetchText start or immediately
when file changes) call setError(null), setPages([]) and setCurrentPage(0) and
then setLoading(true) so the viewer is reset for the new file; update the
fetchText/useEffect block (references: useEffect, fetchText, setError, setPages,
setCurrentPage, setLoading, paginateText, file) to perform these resets before
awaiting fetch and keep the existing cancelled checks.

Comment on lines +178 to +196
<Button
variant="ghost"
size="icon"
className="absolute left-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToPreviousPage}
disabled={currentPage <= 1}
>
<ChevronLeftIcon className="h-6 w-6" />
</Button>

<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToNextPage}
disabled={currentPage >= numPages}
>
<ChevronRightIcon className="h-6 w-6" />
</Button>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add accessible names to icon-only pagination buttons.
Line 178 and Line 188 render icon-only controls without aria-label, which makes page navigation unclear for assistive tech users.

♿ Proposed fix
           <Button
             variant="ghost"
             size="icon"
+            aria-label="Previous page"
+            title="Previous page"
             className="absolute left-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
             onClick={goToPreviousPage}
             disabled={currentPage <= 1}
           >
             <ChevronLeftIcon className="h-6 w-6" />
           </Button>

           <Button
             variant="ghost"
             size="icon"
+            aria-label="Next page"
+            title="Next page"
             className="absolute right-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
             onClick={goToNextPage}
             disabled={currentPage >= numPages}
           >
             <ChevronRightIcon className="h-6 w-6" />
           </Button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Button
variant="ghost"
size="icon"
className="absolute left-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToPreviousPage}
disabled={currentPage <= 1}
>
<ChevronLeftIcon className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToNextPage}
disabled={currentPage >= numPages}
>
<ChevronRightIcon className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Previous page"
title="Previous page"
className="absolute left-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToPreviousPage}
disabled={currentPage <= 1}
>
<ChevronLeftIcon className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Next page"
title="Next page"
className="absolute right-2 top-1/2 z-50 h-10 w-10 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40 hover:text-white disabled:opacity-30"
onClick={goToNextPage}
disabled={currentPage >= numPages}
>
<ChevronRightIcon className="h-6 w-6" />
</Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/documents/preview-viewers/preview-text-viewer.tsx` around lines
178 - 196, The icon-only pagination Buttons for navigating pages (the Button
components that call goToPreviousPage and goToNextPage and are disabled based on
currentPage/numPages) lack accessible names; add appropriate aria-label
attributes (e.g., aria-label="Previous page" and aria-label="Next page") or
aria-labelledby that include current/total context as needed so screen readers
can announce their purpose, ensuring the labels are present on the Button
elements that wrap ChevronLeftIcon and ChevronRightIcon.

Comment on lines +145 to +162
setLoading(true);
const response = await fetch(file);
if (!response.ok) throw new Error("Failed to load text file");
const text = await response.text();
if (cancelled) return;
const paginatedPages = paginateText(text);
setPages(paginatedPages);

const initialPage =
pageQuery >= 1 && pageQuery <= paginatedPages.length ? pageQuery : 1;
setPageNumber(initialPage);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load file");
}
} finally {
if (!cancelled) setLoading(false);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "text-viewer.tsx" --exec wc -l {}

Repository: mfts/papermark

Length of output: 169


🏁 Script executed:

fd -t f "text-viewer.tsx" -x cat -n {}

Repository: mfts/papermark

Length of output: 28880


🏁 Script executed:

fd -t f "text-viewer.tsx" -x sed -n '470,490p' {}

Repository: mfts/papermark

Length of output: 591


Reset error before each load to prevent sticky error UI blocking successful retries.

When a fetch fails, error is set at line 158. If the fetch is retried (file prop changes), the error state is never cleared, so line 479's condition error || !pages remains true even if the retry succeeds. The error UI renders instead of the content.

Proposed fix
   useEffect(() => {
     let cancelled = false;
     async function fetchText() {
       try {
         setLoading(true);
+        setError(null);
         const response = await fetch(file);
         if (!response.ok) throw new Error("Failed to load text file");
         const text = await response.text();
         if (cancelled) return;
         const paginatedPages = paginateText(text);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setLoading(true);
const response = await fetch(file);
if (!response.ok) throw new Error("Failed to load text file");
const text = await response.text();
if (cancelled) return;
const paginatedPages = paginateText(text);
setPages(paginatedPages);
const initialPage =
pageQuery >= 1 && pageQuery <= paginatedPages.length ? pageQuery : 1;
setPageNumber(initialPage);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load file");
}
} finally {
if (!cancelled) setLoading(false);
}
setLoading(true);
setError(null);
const response = await fetch(file);
if (!response.ok) throw new Error("Failed to load text file");
const text = await response.text();
if (cancelled) return;
const paginatedPages = paginateText(text);
setPages(paginatedPages);
const initialPage =
pageQuery >= 1 && pageQuery <= paginatedPages.length ? pageQuery : 1;
setPageNumber(initialPage);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load file");
}
} finally {
if (!cancelled) setLoading(false);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/view/viewer/text-viewer.tsx` around lines 145 - 162, Before
starting the fetch in the text loading routine, clear any previous error so a
past failure doesn't block future successful loads; specifically, call
setError(null) (or setError("") depending on state type) right after
setLoading(true) and before awaiting fetch in the async loader that uses
setLoading, fetch(file), paginateText, setPages and setPageNumber, ensuring the
cancelled check remains in place.

Comment on lines +331 to +346
useEffect(() => {
const removeQueryParams = (queries: string[]) => {
const currentQuery = { ...router.query };
const currentPath = router.asPath.split("?")[0];
queries.map((query) => delete currentQuery[query]);
router.replace(
{ pathname: currentPath, query: currentQuery },
undefined,
{ shallow: true },
);
};

if (!dataroomId && router.query.token) {
removeQueryParams(["token", "email", "domain", "slug", "linkId"]);
}
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the file
fd text-viewer.tsx --type f -exec wc -l {} \;

Repository: mfts/papermark

Length of output: 244


🏁 Script executed:

# Check the file content around the specified lines
fd text-viewer.tsx --type f -exec head -350 {} \; | tail -100

Repository: mfts/papermark

Length of output: 288


🏁 Script executed:

# Get a better view of the imports and overall structure
fd text-viewer.tsx --type f -exec head -50 {} \;

Repository: mfts/papermark

Length of output: 288


🏁 Script executed:

# Search for router imports and usage in this file
fd text-viewer.tsx --type f -exec grep -n "router\|useRouter\|import" {} \;

Repository: mfts/papermark

Length of output: 288


🏁 Script executed:

# Find the file
fd "text-viewer.tsx"

Repository: mfts/papermark

Length of output: 157


🏁 Script executed:

# Once we know the path, read the full file to understand context
git ls-files | grep -E "text-viewer\.tsx"

Repository: mfts/papermark

Length of output: 157


🏁 Script executed:

# Read the file to understand its structure and the specific lines
cat -n components/view/viewer/text-viewer.tsx | head -100

Repository: mfts/papermark

Length of output: 3478


🏁 Script executed:

# Read the specific lines mentioned in the review (331-346) and surrounding context
cat -n components/view/viewer/text-viewer.tsx | sed -n '300,360p'

Repository: mfts/papermark

Length of output: 2047


🏁 Script executed:

# Check imports at the top of the file
cat -n components/view/viewer/text-viewer.tsx | sed -n '1,50p'

Repository: mfts/papermark

Length of output: 1834


🌐 Web query:

Next.js router.isReady hydration query parameter timing

💡 Result:

In the Pages Router (next/router), router.query (and asPath) can be missing/empty on the first render because the page may be pre-rendered (static optimization) and Next.js only fills in those router fields client-side after hydration. The router exposes router.isReady to tell you when those fields have been updated and are safe to read. [1]

Practical pattern (query-string + dynamic params):

import { useEffect } from "react";
import { useRouter } from "next/router";

export default function Page() {
  const router = useRouter();

  useEffect(() => {
    if (!router.isReady) return;           // wait for hydration update
    const { foo, slug } = router.query;    // now stable
    // ...
  }, [router.isReady, router.query]);

  return null;
}

Notes:

  • Next.js docs explicitly warn to avoid using asPath until isReady is true, and that isReady is meant to be used inside useEffect, not for server-conditional rendering. [1]
  • The same “null/empty on initial render, then populated after hydration when the router is ready” behavior is also documented for useParams() on statically optimized pages. [2]
  • If you need values on the server render (no hydration timing), read them in getServerSideProps (or in the App Router, prefer useSearchParams() / useParams() from next/navigation). [1][2]

Sources:
[1] Next.js Docs — useRouter (isReady, query, asPath hydration timing) (nextjs.org)
[2] Next.js Docs — useParams (“returns null on initial render for statically optimized pages; updated after hydration once router is ready”) (nextjs.org)

Citations:


🌐 Web query:

Next.js useRouter router.query undefined before hydration

💡 Result:

In the Pages Router (next/router), router.query can be {} / have undefined fields on the first render because during pre-rendering (Automatic Static Optimization / SSG) Next.js doesn’t yet have access to the browser URL; it fills query after hydration, causing a second render. Next.js documents this behavior and recommends using router.isReady to know when query is usable. [1][2]

Fix (recommended): wait for router.isReady (typically in useEffect)

import { useEffect } from "react";
import { useRouter } from "next/router";

export default function Page() {
  const router = useRouter();

  useEffect(() => {
    if (!router.isReady) return;
    const { id } = router.query; // now reliable
    // ...use id
  }, [router.isReady, router.query]);

  return null;
}

isReady is specifically meant to indicate when router fields (like query / asPath) have been updated client-side. [1][2]

If you need the param on the first render (server/HTML)

Use data fetching so the param is available on the server (e.g., getServerSideProps, or getStaticProps for dynamic routes), instead of relying on useRouter() during prerender. [2]

If you’re on the App Router (app/)

useRouter from next/navigation doesn’t expose query; use:

  • useParams() for dynamic segments, and
  • useSearchParams() for ?key=value query strings. [1]

Sources:
[1] Next.js useRouter (Pages Router) API reference (updated Feb 11, 2026)
[2] Next.js Automatic Static Optimization docs (explains empty query before hydration and using isReady)


Add router.isReady guard before accessing router fields.

The effect runs on mount with an empty dependency array. In Next.js Pages Router, router.query is empty until hydration completes. This means the condition !dataroomId && router.query.token always fails on first render, and the effect never reruns, leaving token/email parameters in the URL.

Add the check at the top of the effect and update dependencies per the Next.js documentation pattern:

Fix
  useEffect(() => {
+   if (!router.isReady) return;
+
    const removeQueryParams = (queries: string[]) => {
      const currentQuery = { ...router.query };
      const currentPath = router.asPath.split("?")[0];
-     queries.map((query) => delete currentQuery[query]);
+     queries.forEach((query) => delete currentQuery[query]);
      router.replace(
        { pathname: currentPath, query: currentQuery },
        undefined,
        { shallow: true },
      );
    };

    if (!dataroomId && router.query.token) {
      removeQueryParams(["token", "email", "domain", "slug", "linkId"]);
    }
- }, []);
+ }, [router.isReady, router.query, router.asPath, dataroomId]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const removeQueryParams = (queries: string[]) => {
const currentQuery = { ...router.query };
const currentPath = router.asPath.split("?")[0];
queries.map((query) => delete currentQuery[query]);
router.replace(
{ pathname: currentPath, query: currentQuery },
undefined,
{ shallow: true },
);
};
if (!dataroomId && router.query.token) {
removeQueryParams(["token", "email", "domain", "slug", "linkId"]);
}
}, []);
useEffect(() => {
if (!router.isReady) return;
const removeQueryParams = (queries: string[]) => {
const currentQuery = { ...router.query };
const currentPath = router.asPath.split("?")[0];
queries.forEach((query) => delete currentQuery[query]);
router.replace(
{ pathname: currentPath, query: currentQuery },
undefined,
{ shallow: true },
);
};
if (!dataroomId && router.query.token) {
removeQueryParams(["token", "email", "domain", "slug", "linkId"]);
}
}, [router.isReady, router.query, router.asPath, dataroomId]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/view/viewer/text-viewer.tsx` around lines 331 - 346, The useEffect
that defines removeQueryParams reads router.query on mount before Next.js
hydration, so add a guard at the top: if (!router.isReady) return; then perform
the existing logic; ensure the effect depends on router.isReady and dataroomId
(and router.query/router.asPath or router as needed) instead of an empty array
so it reruns when hydration completes; keep the helper removeQueryParams and the
router.replace call as-is but trigger only after router.isReady and dataroomId
changes are evaluated.

Comment on lines +74 to +76
const charsPerLine = options?.charsPerLine ?? TEXT_PAGE_DEFAULTS.charsPerLine;
const linesPerPage = options?.linesPerPage ?? TEXT_PAGE_DEFAULTS.linesPerPage;
const tabSize = options?.tabSize ?? TEXT_PAGE_DEFAULTS.tabSize;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Validate pagination options to prevent non-terminating loops.

paginateText accepts caller overrides but does not guard invalid values. If charsPerLine <= 0 (Line 40) or linesPerPage <= 0 (Line 93), pagination can hang indefinitely.

Proposed fix
 export function paginateText(
   rawText: string,
   options?: PaginateTextOptions,
 ): string[][] {
   const charsPerLine = options?.charsPerLine ?? TEXT_PAGE_DEFAULTS.charsPerLine;
   const linesPerPage = options?.linesPerPage ?? TEXT_PAGE_DEFAULTS.linesPerPage;
   const tabSize = options?.tabSize ?? TEXT_PAGE_DEFAULTS.tabSize;
+
+  if (!Number.isInteger(charsPerLine) || charsPerLine < 1) {
+    throw new Error("charsPerLine must be a positive integer");
+  }
+  if (!Number.isInteger(linesPerPage) || linesPerPage < 1) {
+    throw new Error("linesPerPage must be a positive integer");
+  }
+  if (!Number.isInteger(tabSize) || tabSize < 1) {
+    throw new Error("tabSize must be a positive integer");
+  }

   const normalizedText = rawText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/utils/text-pagination.ts` around lines 74 - 76, The paginateText function
currently reads options into charsPerLine, linesPerPage and tabSize without
validation, which allows charsPerLine <= 0 or linesPerPage <= 0 and can cause
non-terminating loops; fix by validating and normalizing those values after they
are assigned (in the block that sets charsPerLine, linesPerPage, tabSize) —
coerce any non-finite or <=0 charsPerLine and linesPerPage to the corresponding
TEXT_PAGE_DEFAULTS values (or throw a clear ArgumentError) and ensure tabSize is
a positive integer as well so the pagination loop in paginateText always
advances.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants