Skip to content

Conversation

@bmanquen
Copy link
Collaborator

@bmanquen bmanquen commented Jan 6, 2026

  • sanitize html before parsing/transforming the string
  • use React Portals instead of React Root
  • update popover for the footnotes to use new popover structure

@bmanquen bmanquen requested a review from cameronapak January 6, 2026 23:19
@changeset-bot
Copy link

changeset-bot bot commented Jan 6, 2026

🦋 Changeset detected

Latest commit: 8807d08

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@youversion/platform-react-hooks Patch
@youversion/platform-core Patch
@youversion/platform-react-ui Patch
nextjs Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector
Copy link

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Jan 6, 2026

Greptile Summary

This PR refactors the footnotes rendering logic to fix persistence issues and improve code architecture.

Key Changes:

  • Replaced React createRoot/unmount with React Portals for rendering footnotes, eliminating the need to manage Root instances and preventing footnotes from disappearing on re-renders
  • Moved HTML sanitization to occur before parsing with DOMParser, improving security by ensuring XSS protection happens earlier in the pipeline
  • Added memo wrapper to VerseFootnoteButton component for performance optimization
  • Changed from useEffect to useLayoutEffect in HtmlWithNotes to ensure DOM manipulation happens synchronously before browser paint
  • Updated popover to use new heading prop instead of manually constructing header with close button
  • Added comprehensive integration test to verify footnotes persist through font size changes

Benefits:

  • Footnotes now properly persist when component re-renders (e.g., font size changes)
  • Cleaner React patterns using Portals instead of imperative DOM manipulation
  • Better security with earlier HTML sanitization
  • More maintainable code with simplified popover usage

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • The refactoring follows React best practices by replacing imperative DOM manipulation (createRoot/unmount) with declarative React Portals. Security is improved with earlier HTML sanitization. The changes are well-tested with a comprehensive integration test that validates the core fix (footnotes persisting through re-renders). Code quality improvements include using memo for performance and useLayoutEffect for proper timing. All changes are focused and purposeful without unnecessary modifications.
  • No files require special attention

Important Files Changed

Filename Overview
packages/ui/src/components/verse.tsx Refactored footnotes to use React Portals instead of createRoot/unmount, sanitized HTML before parsing, and wrapped component with memo for performance
packages/ui/src/components/bible-reader.stories.tsx Added integration test to verify footnotes persist after font size changes, properly tagged with 'integration'
packages/ui/src/test/mock-data/passages.json Updated JHN.1 mock data to include footnotes for testing the new footnotes functionality

Sequence Diagram

sequenceDiagram
    participant User
    participant Verse.Html
    participant yvDomTransformer
    participant DOMPurify
    participant extractNotesFromHtml
    participant HtmlWithNotes
    participant Portal as React Portal
    participant DOM
    participant VerseFootnoteButton

    User->>Verse.Html: Render with HTML content
    Verse.Html->>yvDomTransformer: Transform HTML with extractNotes=true
    yvDomTransformer->>extractNotesFromHtml: Extract footnotes
    extractNotesFromHtml->>DOMPurify: Sanitize HTML first
    DOMPurify-->>extractNotesFromHtml: Sanitized HTML
    extractNotesFromHtml->>extractNotesFromHtml: Parse DOM and extract notes
    extractNotesFromHtml->>extractNotesFromHtml: Create placeholders [data-verse-footnote]
    extractNotesFromHtml-->>yvDomTransformer: {html, notes}
    yvDomTransformer->>DOMPurify: Sanitize transformed HTML
    yvDomTransformer->>yvDomTransformer: Add non-breaking spaces
    yvDomTransformer-->>Verse.Html: {html, notes}
    Verse.Html->>HtmlWithNotes: Render with html & notes
    HtmlWithNotes->>DOM: Set innerHTML with html
    HtmlWithNotes->>HtmlWithNotes: Query placeholders
    HtmlWithNotes->>HtmlWithNotes: Store placeholders in state
    loop For each placeholder
        HtmlWithNotes->>Portal: createPortal(VerseFootnoteButton, placeholder)
        Portal->>DOM: Render button into placeholder
    end
    User->>VerseFootnoteButton: Click footnote button
    VerseFootnoteButton->>VerseFootnoteButton: Open Popover with notes
Loading

@cameronapak cameronapak changed the title fix(ui): fix/refactor the footnotes logic YPE-952: fix(ui): fix/refactor the footnotes logic Jan 7, 2026
- the dom was getting re-rendered multiple times especially when
adding the bible reader settings.
- this change allows the verse text to only be re-rendered when
a different html string is passed in explicitly.
- uses new props on our popover component to reduce
redundancy in the header of the popover.
@cameronapak
Copy link
Collaborator

CleanShot 2026-01-07 at 10 04 54@2x

suggestion (non-blocking): There's a way to better align the footnotes so that the line height of the sentence that it's in, or the paragraph that it's in, doesn't get messed up because of the footnote. This isn't something that you need to do in this at all. I just wanted to share that I found a simple way to fix it. On the component that has data-verse-footnote I gave it styles of:

{
    display: inline-flex;
    vertical-align: top;
    margin-left: 0.25rem;
}
CleanShot 2026-01-07 at 10 02 21@2x

cameronapak
cameronapak previously approved these changes Jan 7, 2026
Copy link
Collaborator

@cameronapak cameronapak left a comment

Choose a reason for hiding this comment

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

praise: The code itself looks good and the tests are clean. I've never used React portals yet, so this was a fun thing to see and learn a little bit about.

question: Do you feel that this is stable and that footnotes are in a good spot?

@cameronapak
Copy link
Collaborator

question: @greptileai, my teammate did a great job getting this working and working well. I was wondering, with the recent Web Popover API stuff, is that something that could replace this React-based approach and simplify things? I'm asking just for an understanding, not for exact technical implementation details or to change this task.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Jan 7, 2026

Looking at this PR that's implementing footnotes with React Portals and a popover structure, that's a great question about the Web Popover API!

The Web Popover API could potentially simplify this approach, but there are some important considerations:

Where it could help:

  • Positioning: The native popover API handles positioning automatically relative to the trigger element, which eliminates a lot of the manual positioning logic React popovers typically need
  • Focus management: Built-in focus trapping and keyboard navigation
  • Accessibility: Native ARIA attributes and screen reader support
  • Layer management: Automatic top-layer rendering without needing portals
  • Simpler state management: No need to manage open/close state in React

Current limitations:

  • Browser support: Still not universal (though getting close)
  • Styling constraints: Less granular control over animations and transitions
  • React integration: You'd still need React to manage the content, just not the positioning/portal logic
  • Complex interactions: For footnotes with rich content or interactive elements, React might still be easier to manage

For footnotes specifically, the native popover API could be really nice since they're typically simple content that appears on hover/click with straightforward positioning needs. Your teammate's React Portal approach gives you full control and guaranteed compatibility across all browsers right now, while the Web Popover API could be a future simplification once browser support is more complete.

It's definitely worth keeping an eye on as the API matures!

@bmanquen
Copy link
Collaborator Author

bmanquen commented Jan 7, 2026

praise: The code itself looks good and the tests are clean. I've never used React portals yet, so this was a fun thing to see and learn a little bit about.

question: Do you feel that this is stable and that footnotes are in a good spot?

Yes I think it is now much more stable after I make your suggested CSS change!

cameronapak
cameronapak previously approved these changes Jan 7, 2026
Copy link
Collaborator

@cameronapak cameronapak left a comment

Choose a reason for hiding this comment

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

Thank you for submitting the changeset. This is approved.

@cameronapak
Copy link
Collaborator

@greptileai thanks for your response #94 (comment)

Since the HTML comes back as a string, we can modify the string. Or we can use a virtual DOM or something to modify the string, to modify the element so that it has the popover API information stuff that it needs.

Let's say we did this approach, and we did a more web standards first approach and not take out the React complexity of this feature because it starts out as HTML and then would be rendered as HTML.

How would this change the file? Like how would this change the footnotes file? Would it make the code a lot simpler? Would it still do the same things?

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

Looking at the current implementation, using the native Popover API would indeed simplify the code significantly. Here's how it would change:

Current Complexity

The current approach:

  1. Parses HTML to extract footnote data
  2. Uses React Portals to render React components into DOM placeholders
  3. Manages React state and lifecycle for popovers
  4. Requires complex coordination between HTML parsing and React rendering

Web Standards Approach

With the Popover API, you'd modify the HTML string directly to add the necessary attributes and content:

'use client';

import { useLayoutEffect, forwardRef, useRef, type ReactNode } from 'react';
import DOMPurify from 'isomorphic-dompurify';
import { usePassage } from '@youversion/platform-react-hooks';

const NON_BREAKING_SPACE = '\u00A0';

type VerseProps = {
  html?: string;
  extractNotes?: boolean;
  reference?: string;
  className?: string;
  children?: ReactNode;
};

function transformFootnotesToPopoverAPI(html: string): string {
  if (typeof window === 'undefined') return html;

  const sanitizedHtml = DOMPurify.sanitize(html, DOMPURIFY_CONFIG);
  const parser = new DOMParser();
  const doc = parser.parseFromString(sanitizedHtml, 'text/html');
  const noteElements = doc.querySelectorAll('span.yv-n.f');
  
  noteElements.forEach((noteEl, index) => {
    const footnoteId = `footnote-${index}`;
    const popoverId = `popover-${index}`;
    
    // Get the verse number from context
    const verseEl = noteEl.closest('[data-verse]') || 
                   noteEl.parentElement?.querySelector('.yv-v[v]');
    const verseNum = verseEl?.getAttribute('v') || '';
    
    // Create footnote button with popover trigger
    const button = doc.createElement('button');
    button.className = 'yv:inline-flex yv:items-center yv:justify-center yv:w-4 yv:h-4 yv:text-xs yv:bg-blue-100 yv:text-blue-700 yv:rounded-full yv:ml-1 yv:align-super hover:yv:bg-blue-200';
    button.setAttribute('popovertarget', popoverId);
    button.setAttribute('data-verse-footnote', verseNum);
    button.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>';
    
    // Create popover content
    const popover = doc.createElement('div');
    popover.id = popoverId;
    popover.setAttribute('popover', 'auto');
    popover.className = 'yv:bg-white yv:border yv:rounded-lg yv:shadow-lg yv:p-4 yv:max-w-sm';
    
    const header = doc.createElement('div');
    header.className = 'yv:font-bold yv:mb-2';
    header.textContent = `Footnotes - Verse ${verseNum}`;
    
    const content = doc.createElement('div');
    content.innerHTML = noteEl.innerHTML;
    
    popover.appendChild(header);
    popover.appendChild(content);
    
    // Replace the footnote span with button and append popover to body
    noteEl.replaceWith(button);
    doc.body.appendChild(popover);
  });

  return doc.body.innerHTML;
}

function yvDomTransformer(html: string, extractNotes: boolean = false): string {
  let processedHtml = html;

  if (extractNotes) {
    processedHtml = transformFootnotesToPopoverAPI(html);
  } else {
    processedHtml = DOMPurify.sanitize(html, DOMPURIFY_CONFIG);
  }

  // Safely parse and modify HTML to add spaces to paragraph elements
  const parser = new DOMParser();
  const doc = parser.parseFromString(processedHtml, 'text/html');

  // Add non-breaking spaces to verse labels
  const verseLabels = doc.querySelectorAll('span.yv-vlbl');
  verseLabels.forEach((label) => {
    if (label.textContent && !label.textContent.endsWith(NON_BREAKING_SPACE)) {
      label.textContent += NON_BREAKING_SPACE;
    }
  });

  return doc.body.innerHTML;
}

// Configure DOMPurify to allow popover attributes
const DOMPURIFY_CONFIG: DOMPurify.Config = {
  ALLOWED_TAGS: [
    'div', 'span', 'p', 'br', 'strong', 'em', 'i', 'b', 'u', 's', 'sup', 'sub', 'small',
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'cite', 'q',
    'abbr', 'acronym', 'time', 'code', 'pre', 'kbd', 'samp', 'var', 'mark', 'ins', 'del',
    'button', // For footnote buttons
  ],
  ALLOWED_ATTR: [
    'class', 'id', 'title', 'lang', 'dir', 'style', 'data-*', 'v', 'usfm',
    'popover', 'popovertarget', // Popover API attributes
  ],
  ALLOW_DATA_ATTR: true,
};

export const Verse = forwardRef<HTMLDivElement, VerseProps>(
  ({ html, extractNotes = false, reference, className, children, ...props }, ref) => {
    const { passage } = usePassage();
    const contentRef = useRef<HTMLDivElement>(null);

    const content = html || passage?.content || '';
    const processedHtml = yvDomTransformer(content, extractNotes);

    useLayoutEffect(() => {
      if (!contentRef.current) return;
      contentRef.current.innerHTML = processedHtml;
    }, [processedHtml]);

    return (
      <div ref={ref} className={className} {...props}>
        <div ref={contentRef} />
        {children}
      </div>
    );
  },
);

Verse.displayName = 'Verse';

Benefits of This Approach

  1. Much Simpler: No React Portals, no state management, no complex lifecycle coordination
  2. Web Standards: Uses native Popover API - better accessibility and performance
  3. Fewer Dependencies: No need for complex React popover components
  4. Better Performance: Native browser implementation is faster than React rendering
  5. Cleaner Architecture: Pure HTML transformation, no React/DOM boundary crossing

Same Functionality

  • Footnotes still appear as clickable buttons
  • Popovers still show footnote content
  • Still sanitizes HTML for security
  • Still maintains verse references and styling

The main trade-off is that you lose some React component flexibility (like custom styling via props), but gain significant simplicity and performance. The popover styling would be handled via CSS rather than React component props.

detection

- Fix verse boundaries to work across paragraph elements using
  TreeWalker
- Add `v` and `usfm` to DOMPurify allowed attributes (was stripping
  verse markers)
- Place footnote icons at actual verse end, not next verse's paragraph
- Exclude headers (.yv-h) from popover verse text
- Add spacing between paragraphs in reconstructed verse text
- Style footnote markers (A, B, C) with muted foreground color
- Inherit reader font size in popover verse content
- Remove verse number from popover verse text (shown in header already)
- Add tests for footnote extraction and placement
- Simplify and refactor extraction logic (~145 to ~85 lines)
@bmanquen bmanquen changed the title YPE-952: fix(ui): fix/refactor the footnotes logic YPE-953: fix(ui): fix/refactor the footnotes logic Jan 8, 2026
Comment on lines +286 to +304
it('should handle multiple footnotes in a single verse', async () => {
const htmlWithMultipleNotes = `
<div class="p">
<span class="yv-v" v="51"></span><span class="yv-vlbl">51</span>He then added,
<span class="wj">"Very truly I tell you,</span><span class="yv-n f"><span class="fr">1:51 </span><span class="ft">The Greek is plural.</span></span>
<span class="wj">you</span><span class="yv-n f"><span class="fr">1:51 </span><span class="ft">The Greek is plural.</span></span>
<span class="wj">will see heaven open."</span>
</div>
`;

const { container } = render(<Verse.Html html={htmlWithMultipleNotes} renderNotes={true} />);

await waitFor(() => {
const placeholder = container.querySelector('[data-verse-footnote="51"]');
expect(placeholder).not.toBeNull();
const footnoteElements = container.querySelectorAll('.yv-n.f');
expect(footnoteElements.length).toBe(0);
});
});
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This test doesn't seem to be asserting that there are multiple footnotes in the popover element.

Comment on lines +116 to +122
} else if (
!node.classList.contains('yv-v') &&
!node.classList.contains('yv-vlbl') &&
!node.childNodes.length
) {
text += node.textContent || '';
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

What case is this else if cover? It seems like this will never get hit.

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.

3 participants