- GraphQL Integration: Fetch user karma and comment agreement data for enhanced filtering.
- Modern UI: Use a sleek side-panel or floating dashboard instead of raw DOM injection.
- Performance: High-speed threading was a core feature of the original; maintain that using modern JS/async patterns.
In this project (type: "module"), __dirname is not available.
- Problem: Playwright tests or Node scripts will crash if they use
__dirname. - Solution: Use
fileURLToPathandimport.meta.urlto derive paths (seetests/power-reader.spec.ts).
Vite-plugin-monkey defaults to userscripts.user.js.
- Problem: Multiple scripts in the monorepo will overwrite each other in
dist/. - Solution: The
vite.config.tsdynamically setsbuild.fileNamebased on theVITE_SCRIPTenvironment variable.
The classic Vite "Vanilla TS" template includes assets (CSS/SVGs) that break when moving to a subfolder monorepo.
- Problem:
npm run buildfails with "Could not resolve" errors. - Solution: Keep userscript entry points (
main.ts) minimal or strictly relative to their script folder.
- Problem: TypeScript doesn't recognize Greasemonkey APIs (
GM_*) even with@types/greasemonkey. - Solution: Use
/// <reference types="vite-plugin-monkey/client" />at the top of files that use these APIs. This allowsvite-plugin-monkeyto handle the types correctly without extra dependencies.
- Problem: React sites (like LessWrong) keep running in the background even if you hide their UI. They may try to update the DOM, causing
NotFoundErrorwhen they can't find their expected nodes. - Solution: Use
window.stop()immediately atdocument-start, block all future script injections viaMutationObserver, and resetdocument.documentElement.innerHTML = ''. This kills the original React environment entirely.
- Context: LW API does not provide reaction definitions.
- Solution: We scrape the
client.*.jsbundle using regex (name:"agree",label:"Agreed"). This is brittle but necessary. - Gotcha: If LW changes their build system or obfuscation, this will break. Always maintain a hardcoded fallback list in
reactions.ts.
- Problem: Older documentation (including our local
GraphQL.md) may referenceplaintextExcerpt. - Solution: Always use
htmlBodyfor comment content. Check the latest schema in GraphiQL if queries fail with "field not found".
- Problem: The
extendedScorefield in GraphQL is typed asJSONbut has a specific internal structure. - Gotcha: It is NOT a simple list of reactions. It is a Record (
{ [reactionName]: UserReactInfo[] }). - Test Mocking: Your mocks MUST reflect this structure. Returning a flat list or simple counts will crash the UI renderer which expects to iterate over keys.
- Generic Queries: The
queryGraphQLhelper is now generic:queryGraphQL<TData, TVariables>(QUERY, variables). ALWAYS use the generated types fromsrc/generated/graphqlwhen calling it. This enforces type safety for variables and return values, preventing runtime errors due to missing or mismatched variables.
- Problem: The LessWrong GraphQL server sometimes returns a schema that is technically invalid (e.g.,
EmptyViewInputwith no fields), which causesgraphql-codegento crash with "Input Object type EmptyViewInput must define one or more fields." - Solution: Use the
npm run update-schemacommand. It runs a specialized script (src/shared/graphql/fetch_schema.js) that uses Playwright to fetch the introspection result through a browser (bypassing the production API's CORS/User-Agent restrictions) and applies a post-processing fix to inject a dummy_unusedfield into any empty Input objects before saving the JSON.
- Problem: LessWrong mutations wrap the updated document within a result object (e.g.,
performVoteComment { document { ... } }). - Solution: Mocks in tests must match this nesting exactly. The userscript's UI synchronization logic expects
response.performVoteComment.document. If the mock returns a top-levelvoteorcommentobject instead, the update will be ignored, leading to inconsistent UI states in tests.
- Problem: Fetching data for many independent IDs (e.g., context for 50 different comments) often hits the "800 threads" bottleneck if done one-by-one.
- Solution: Use GraphQL Aliasing to group multiple queries into one request.
query Batch($id0: String!, $id1: String!) { r0: comments(selector: { commentReplies: { parentCommentId: $id0 } }) { results { ... } } r1: comments(selector: { commentReplies: { parentCommentId: $id1 } }) { results { ... } } }
- Implementation: See
fetchRepliesBatchandfetchThreadsBatchinloader.ts. Chunk size is limited to ~15-30 to avoid hitting HTTP header size limits.
- Strategy: Instead of recursive "Walk Up" calls, include several levels of shallow
parentComment(just_idandparentCommentId) in the initial comment fragment. - Benefit: 5-10 levels covers 99% of threads, allowing the reader to build a complete ancestry map for 0 extra network cost during initial load.
- Problem: The old per-post thread loading approach was complex and required syncing read state with the server.
- Solution: Use
allRecentCommentsview withafterparameter for simple linear chronological loading. StoreloadFromdatetime locally and update it when user scrolls to bottom. No server sync needed.
- Context: Merging posts and comments into a single feed can lead to an "infinite range" problem if not capped. If a user has 10,000 unread comments over a month, fetching posts for that entire month while only showing 800 comments leads to a massive sync issue.
- Solution: Cap the post fetch date range. Fetch comments first to determine the exact time window they cover. Use the oldest comment's date as
afterand the newest comment's date asbefore(if the comment batch hit the limit). This ensures posts and comments are strictly synchronized in the current view.
- Problem: When using
position: fixedfor UI elements (like a global picker), usingrect.bottom + window.scrollYfromgetBoundingClientRect()will cause the element to appear off-screen if the page is scrolled. - Solution: For
fixedelements, use the raw coordinates fromgetBoundingClientRect()as they are already relative to the viewport. Only add scroll offsets forabsolutepositioned elements.
- Problem: Using
e.target.dataset.actionin a root click listener fails if the user clicks an icon or text inside the button. - Solution: Use
(e.target as HTMLElement).closest('[data-action]')to correctly identify the action regardless of which child was clicked.
- Problem: Hardcoded pixel thresholds for marking comments as read (e.g.,
rect.bottom < 100) are fragile and depend on viewport size. - Solution: Use relative values like
window.innerHeight * 0.8to ensure consistent behavior across different screen resolutions and test environments.
- Problem: Even with "mark only when fully scrolled past" logic, unread comments can get stuck at the bottom of the page because they never leave the screen.
- Solution: Implement a "Bottom of Page" condition in the scroll listener. If
window.innerHeight + window.scrollY >= document.body.offsetHeight, mark all currently visible unread comments as read immediately.
- Problem: Changing state (like collapsing a post) via a sticky header feels disconnected if the original post header doesn't stay in sync.
- Solution: When clicking buttons in the sticky header, perform the action on the original DOM elements (e.g., triggering a scroll to the post before collapsing). This ensures that if the user scrolls back up, the UI state is consistent.
- Problem: A UI component (like a picker) closes instantly when an internal button (like a view toggle) is clicked, even though the click was "inside".
- Cause: If clicking the button triggers a re-render (
innerHTML = ...), the button is removed from the DOM. However, theclickevent continues to bubble up todocument. A "Click Outside" handler on the document checksif (!picker.contains(event.target)). Since the button is now detached,containsreturnsfalse, and the picker closes. - Solution: Use
e.stopPropagation()on internal buttons that trigger re-renders to prevent the event from reaching the document-level "Click Outside" listener.
- Problem: Clicking the
+button opens the reaction picker, but it disappears immediately. - Cause: The picker opens on
mousedown, but the subsequentclickevent bubbles to a document-levelcloseHandler. That handler checkif (!picker.contains(e.target) && e.target !== button)failed if the user clicked a child element of the button (like an icon or the "+" text itself). - Solution: Use
!button.contains(e.target as Node)instead of a direct equality check. This ensure that clicks anywhere on or inside the button are ignored by the click-away logic.
- Problem: Tooltips inside scrollable containers (like the Reaction Picker) are clipped by
overflow: hiddenor restricted by the container's width. - Solution: Move to a Global Tooltip pattern. Render a single tooltip div at
document.bodylevel. - Implementation: Use
mouseover/mouseoutdelegation on the container. Calculate the tooltip position usinggetBoundingClientRect()of the trigger and adjust for viewport boundaries (flip/shift). - Gotcha: Remember to hide the global tooltip when the parent component (e.g. the Picker) closes.
- Problem: Calling
render()on every state change (like a reaction vote or search input) destroys the current focus and scroll position. - Solution:
- Separate the data state from the UI rendering.
- In
_render(), re-injectinnerHTMLbut specifically handle focus: if the search input was focused, focus it again after re-rendering and restore the cursor position (setSelectionRange). - For scroll preservation in long lists, consider only re-rendering specific nodes or using a virtual DOM approach (though for a picker, a simple
innerHTMLrefresh with focus restoration is usually sufficient).
- Problem: Marking a post as "read" only after scrolling past its entire container (including all comments) is frustrating for users who want the post marked read as soon as they finish the main text.
- Solution: In
ReadTracker.ts, we perform a specialized check for.pr-postitems. We extract the bounding box of the.pr-post-content(the body) instead of the whole post group. If the body has been scrolled past, the post is marked read, even if dozens of unread comments remain visible below it. - Gotcha: If the post body is collapsed or missing (header-only post), we fall back to checking the
.pr-post-headerinstead. This ensures header-only posts are still marked read correctly.
- Problem: The
[e](toggle expansion) button is disabled on load even for very long posts. - Cause: The
refreshPostActionButtonsutility determines if a post "fits" (and thus doesn't need a toggle) by checkingscrollHeight > offsetHeight. If the container has the.truncatedclass but no CSS-enforcedmax-heightyet, the element renders at its natural full height. ThusscrollHeight == offsetHeight, and the utility erroneously disables the button. - Solution: Apply the
max-heightconstraint as an inline style during the initial render whenever the.truncatedclass is applied. This ensures the first layout pass correctly reflects the clipped state for measurement logic.
- Context: Injecting links into a live React application (like the LessWrong forum) is risky because React's "Hydration" process expects the DOM to exactly match its server-rendered state. If a userscript modifies the DOM too early, React may crash with a "Hydration Mismatch" (Error #418).
- Solution:
- Delay: Wait 2 seconds after
window.loadbefore performing the initial injection. - Guarded Observer: Use a
MutationObserverto maintain the link during SPA navigations, but only act if the initial hydration window has passed. - Pre-Injection Check: Always verify
document.getElementById('link-id')is missing before injecting to avoid duplicates.
- Delay: Wait 2 seconds after
- Problem: A flexbox list with
flex-wrap: wrapandwidth: 50%items will try to expand its container to accommodate the longest un-wrapped label, breaking a parent container that is supposed to be sized by a different child (the grid). - Solution: Apply
width: 0; min-width: 100%to the wrapping container. This trick forces the flex container to ignore its children's intrinsic width when the parent is calculating its ownfit-contentsize, effectively making the container "follow" the width dictated by other elements (like a fixed-width grid header) while still filling the available 100% once the parent size is decided.
- Problem: Posts originally had a very different visual style (big headers, no vote buttons in metadata), which made the feed look inconsistent.
- Solution: Standardize post metadata to match the comment-meta block. Use a
renderPostMetadatahelper that shares logic withrenderCommentfor timestamps, authors, and reactions. This provides a clean, unified "Power Reader" feed experience.
- Problem: Next.js/React rendering of footnotes often wraps the content in a
<p>which causes a line break after the^backlink, making the UI jumpy. - Solution: Apply
display: inline !importantandmargin: 0 !importantto both the footnote wrapper and any internal paragraphs to force a single-line flow.
- Context: Users expect
[^]to do something for top-level comments. - Solution: Map the parent of a top-level comment to the parent Post. This provides a consistent navigation experience rather than having the button be a "no-op".
- Problem: When a preview is triggered near the screen edge, centering it on the trigger might cause the preview to overlap the trigger itself, or be "clamped" back into the viewport by the browser, leading to flickering or obscured triggers.
- Solution: Implement a
wasClampedcheck. If the preview's calculated left/right coordinate was modified to stay within the viewport (clamp), AND it still overlaps the trigger's horizontal bounds, force it to the opposite side of the trigger. - Gotcha: A vertical centering typo (
tr.widthinstead oftr.height) will cause off-center previews that trigger "overlap" logic too easily.
- Problem: When navigating to or highlighting a parent, if you apply the highlight class (
.pr-parent-hover) before checking visibility, the class itself (which adds a border or background) can cause the element to fail its own visibility check if the border makes it overlap with a floating header. - Gotcha: Even worse, if you use
document.elementFromPoint(), the element that is highlighted might "block" the sampler from seeing it as the primary element if there are Z-index or margin issues. - Solution: Always perform the Visibility Check FIRST, then apply the highlight classes. This ensures a clean sample of the DOM in its "natural" state.
- Problem: Hover and preview tests in Playwright are notoriously flaky because the application's "Intentional Hover" check rejects events if the mouse hasn't moved recently OR if a scroll occurred within 300ms.
- Solution:
- Scroll Cooldown: After any scroll or page load, add
page.waitForTimeout(500)in tests to wait out the intentionality window. - Explicit Mouse Move: Use
page.mouse.move(0, 0)followed by a move to the target's center. - Event Dispatch: Manually dispatching
dispatchEvent('mouseenter')after moving the mouse is the most reliable way to satisfy both Playwright's hit-testing and the script'slastMouseMoveTimerequirements.
- Scroll Cooldown: After any scroll or page load, add
- Problem: Manual
window.scrollTowithbehavior: 'smooth'is unpredictable in tests and doesn't handle fixed headers (like the sticky header) consistently across different posts. - Solution: Use the
smartScrollToutility insrc/scripts/power-reader/utils/dom.ts. - Features:
- Automatically respects
(window as any).__PR_TEST_MODE__to useinstantinstead ofsmoothbehavior. - Dynamically calculates offsets based on the target post's specific header height (handling variable line counts in titles).
- Handles the "Bottom Clamping" syndrome in tests by providing a reliable target calculation.
- Automatically respects
- Gotcha: If you navigate to a post itself, avoid
block: 'center'. Usingwindow.scrollToto align thepostHeader's top withwindow.pageYOffsetensures a perfectly seamless transition to the sticky state (which triggers attop < 0).
- Problem: Inline styles (like those used for score or recency colors) take precedence over CSS classes even if both use
!important. This can cause highlights like.pr-parent-hoverto be invisible if the element already has a recency color. - Solution:
- Avoid using
!importantin inline styles injected by the renderer if a CSS class needs to override them. - Use CSS Variables for secondary highlights (like recency) and apply them via a low-priority rule, then override them or use
!importantclasses only for high-priority temporary highlights (like hover/parent nav). - Example:
.pr-comment[style*="--pr-recency-color"]attribute selector shared same specificity as classes, so the order instyles.tsmatters.
- Avoid using
- Context: We removed
content-visibility: autofrom post/comment containers to keep layout and hit-testing behavior predictable. - Result:
document.elementFromPoint()-driven logic no longer depends on temporary layout-forcing helpers. - Debugging direction: If parent highlighting/trace logic misbehaves, investigate scroll math, sticky header offsets, and selector targeting before adding layout reflow workarounds.
- Problem: Components (like
ReadTrackerorReactionPicker) that are initialized once but need to access a reactive/updating data source (likecommentsData) can end up with stale data if it's passed directly as a value. - Solution: Pass a getter function
() => commentsDatainstead. This ensures that every time the component accesses the data, it gets the most recent reference from the main module's state.
- Problem: When loading post content inline, we must update the global
postsDataset so that if the user performs a re-render (e.g., toggling a preference), the newly loaded content doesn't disappear. - Solution: Push the newly fetched
Postobject into the globalpostsDataarray and immediately re-render that specific post group. SincerenderUIalso uses this data, the load is persistent across the session.
- Problem: Main-reader and archive hosts previously had duplicated post-group rerender code (DOM replace, expansion restoration, link-preview reattachment, and anchor viewport correction), which was drifting and causing regressions.
- Solution: Use
rerenderPostGroupShared(src/scripts/power-reader/render/rerenderPostGroupShared.ts) from both hosts. - Gotcha: Anchor preservation is a two-pass correction (initial delta + residual correction thresholded by
VIEWPORT_CORRECTION_EPSILON_PX) and should stay centralized to avoid behavior divergence.
- Problem: View-transition and overflow-anchor suppression logic was duplicated across host/event files, with inconsistent cleanup behavior.
- Solution: Use
runWithViewTransitionandwithOverflowAnchorDisabledfromsrc/scripts/power-reader/utils/viewTransition.ts, andlogFindParentTracefromsrc/scripts/power-reader/utils/findParentTrace.ts. - Gotcha: Transition cleanup intentionally falls back to
updateCallbackDone.finally(...)whenfinishedis unavailable.
- Problem: Transient flags (
forceVisible,justRevealed,contextType) were set/read via repeated(comment as any)casts across render/event/merge paths, making merges easy to regress. - Solution: Use helpers in
src/scripts/power-reader/types/uiCommentFlags.ts(markCommentRevealed,setJustRevealed,getCommentContextType,copyTransientCommentUiFlags, etc.). - Gotcha:
clearCommentContextTypesetscontextTypetoundefined(instead ofdelete) intentionally, to keep behavior consistent across merge and serialization paths.
- Problem: Userscript APIs (like
GM_xmlhttpRequest) don't exist in a standard browser. Tests fail when the script tries to run. - Solution: Use
page.addInitScriptto mock these APIs before injecting the userscript. For GraphQL, mock the specific responses needed for the UI to render.
- Problem: The internal
browser_subagenttool may fail if environmental variables (like$HOME) are not set in its sandbox. - Solution: Fall back to the local Playwright setup via
run_commandin your terminal to generate screenshots and verify UI.
- Problem: In Playwright mocks, using
query.includes('currentUser')to identify the user query will also match theGetRecentCommentsquery, because the latter contains fields likecurrentUserVote. This causes the test to incorrectly return a user object when comments are expected, leading to "Cannot read properties of undefined (reading 'results')" errors.
- Problem: Userscripts often fire boilerplate setup queries like
GetSubscriptionsorGetActiveThreadsduring initialization. If your Playwright mock doesn't handle these, the script might hang or throw errors before reaching the code you actually want to test. - Solution: Check the console logs for "Unhandled Mock Query" (if using the helper logs) and ensure all startup queries return at least an empty data structure
{ data: {} }.
- Problem: Unexpected background requests (e.g. tracking, auto-refresh) can cause mocks to return
undefined, hanging theGM_xmlhttpRequestpromise. - Solution: Always include a safe default in your mock handlers:
let responseData: any = { data: {} };. Only overwrite it if a specific query is matched.
- Problem: Mock data IDs (e.g.
postId: 'p1') must strictly match the selectors or logic used in the test. - Solution: Avoid varying ID formats (e.g. mixing
post-1andp1) across different mock objects within the same test suite.
- Problem:
page.waitForSelector('#id')waits for the element to be visible by default. If your "ready signal" is a hidden div, the test will timeout. - Solution: Always use
await page.waitForSelector('#id', { state: 'attached' })for hidden signal elements.
- Problem: Generic selectors (e.g.
.class-name) crash if they match multiple elements, whereas jQuery/DOM APIs often just pick the first one. - Solution: Be explicit. Use
.first()if you truly don't care, or refine the selector to be unique. This catches bugs where duplicate UI components render unexpectedly.
- Problem: Removing a reaction (like "insightful") from the primary section of the picker caused tests to fail because they expected a specific count of elements.
- Lesson: If tests rely on
toHaveCountfor UI sections, ensure those sections contain stable sets of elements. Conversely, when scraping dynamic data, tests should be flexible or verify the existence of specific critical items rather than fixed counts.
- Problem: Running long test suites locally or in CI can generate hundreds of log files, eating up disk space.
- Solution: Implement a
cleanupLogsmethod in the custom test reporter (FileReporter.ts) that reads thetest_logs/directory and unlinks old files, keeping only a fixed window (e.g., last 50 runs).
- Problem: E2E tests often contain long, brittle locators (e.g.,
page.locator('#power-reader-root .pr-comment').first()). If the CSS classes change, every test file needs an update. - Solution: Encapsulate locators and common actions (like
waitForReady) in aPowerReaderPageclass. This provides a central place for maintenance and allows for cleaner, more expressive test code (await prPage.getComment(0)).
- Problem: The "intentional hover" logic relies on detecting a
mousemoveevent within a short window (50ms). Playwright's.hover()can sometimes trigger too quickly or without a precise enough mouse move to be caught by the script's listeners in high-concurrency environments. - Solution: In tests, always:
- Add a small
page.waitForTimeout(500)after page injection to let the reader settle. - Follow
await locator.hover()with a slight coordinate move usingpage.mouse.move(...)inside the element's bounding box. This guarantees amousemoveevent that satisfies the intentionality check.
- Add a small
- Problem: Reaction scraping fails in simple test environments because the mock HTML doesn't contain the expected Next.js script bundles.
- Solution: The readers falls back to
BOOTSTRAP_REACTIONSin these cases. If a test specifically requires non-bootstrap reactions (likelaugh), the mock must either provide the relevant script tags or pre-fill thepower-reader-scraped-reactionsGM storage inbeforeEach.
- Problem: Adding logic to check for post content visibility (e.g., in
linkPreviews.ts) can break existing E2E tests if their mockPostobjects only contain thetitleand_id, but the logic now returnsnullifhtmlBodyis missing. - Solution: Always provide a non-empty
htmlBodyin your test mocks for "Fully Loaded" posts, and specifically test with emptyhtmlBodyfor header-only (unloaded) scenarios.
- Problem: In complex UI tests (like
preview-smart-positioning.ui.spec.ts), using generic classes like.pr-find-parentand relying on.first()or.last()is brittle. If worker A and worker B both use the same mock HTML, Playwright might get confused or match multiple elements in a way that breaks "Strict Mode". - Solution: Inject unique metadata into the test HTML. Using
data-side="left"anddata-side="right"allows locators to be perfectly unambiguous even if the DOM structure is identical across different test cases.
- Problem: The
tests/api-sanity.spec.tssuite hits the livelesswrong.com/graphqlendpoint to verify schema integrity. These requests are frequently blocked by Vercel's security checkpoint with a429 Too Many Requestsstatus when using standard PlaywrightrequestAPI. - Diagnosis: Check the test log for
[api-sanity] BatchA HTTP 429orVercel Security Checkpoint. - Solution: We have refactored the test (
api-sanity.spec.ts) to usepage.evaluate(fetch)inside a real browser context. This inherits the browser's User-Agent and TLS fingerprint, significantly reducing the chance of being blocked. If failures persist, it indicates a strict IP ban; wait for it to clear or run tests on a different network.