Skip to content

YPE-1143: Mitigate BibleCard UI page shifting#177

Open
cameronapak wants to merge 16 commits intomainfrom
cp/YPE-1143-react-sdk-bible-card-formerly-bible-widget-view-fix-fouc
Open

YPE-1143: Mitigate BibleCard UI page shifting#177
cameronapak wants to merge 16 commits intomainfrom
cp/YPE-1143-react-sdk-bible-card-formerly-bible-widget-view-fix-fouc

Conversation

@cameronapak
Copy link
Collaborator

@cameronapak cameronapak commented Feb 27, 2026

More info can be found in https://lifechurch.atlassian.net/browse/YPE-1143

Plz watch video demo first (1min)

Supporting SMOL video

CleanShot.2026-02-27.at.11.31.53.mp4

Replace static loading text with a spinning loader icon when book title
is not yet available. This improves the user experience by providing a
more visually clear indication that content is being fetched.
made it possible for the Bible text view to animate in height so that
changes aren't jarring in the Bible card whenever it loads fully.
Add a pulse animation to the BibleTextView when content is loading. This
improves
the user experience by providing visual feedback during data fetching.
Refactors the `AnimatedHeight` component to use `offsetHeight` for more
accurate height calculations. Also updates the Tailwind transition
timing function to `ease-out` for a smoother animation.
@changeset-bot
Copy link

changeset-bot bot commented Feb 27, 2026

🦋 Changeset detected

Latest commit: fa2afe8

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-ui Patch
@youversion/platform-react-hooks Patch
@youversion/platform-core 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

cameronapak and others added 2 commits February 27, 2026 11:37
…er loading state

- Add role="status" and aria-label="Loading" to LoaderIcon for accessibility
- Update bible-card and verse tests to use getByRole queries instead of CSS class selectors
- Fix verse story to properly await loading assertion

Amp-Thread-ID: https://ampcode.com/threads/T-019ca029-6aeb-73f2-970f-b06ef26ca45f
Co-authored-by: Amp <amp@ampcode.com>
@cameronapak cameronapak marked this pull request as ready for review February 27, 2026 17:38
@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.

@cameronapak
Copy link
Collaborator Author

@greptileai, please review this PR

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 27, 2026

Greptile Summary

This PR successfully mitigates UI page shifting in BibleCard by implementing smooth height transitions and improved loading states. The solution introduces a new AnimatedHeight component that uses ResizeObserver for automatic height tracking with CSS transitions, and a useDelayedLoading hook that prevents spinner flashing by delaying its appearance for 250ms during refetches.

Key improvements:

  • New AnimatedHeight component with ResizeObserver and motion-reduce support
  • Delayed loading spinner (250ms) prevents UI flashing during quick refetches
  • Old content remains visible during refetch instead of showing spinner
  • LoaderIcon now has proper accessibility attributes (role="status", aria-label)
  • Comprehensive test coverage for delayed spinner and refetch behavior
  • Consistent loading UX across BibleCard and BibleReader components

Technical implementation:

  • useDelayedLoading hook properly cleans up timers to prevent memory leaks
  • AnimatedHeight properly cleans up ResizeObserver on unmount
  • Loading states correctly differentiate between initial load (show spinner) vs refetch (show old content + small spinner)
  • Previous error handling issue from review thread has been fixed (now only checks currentError)

All changes follow project conventions, respect package boundaries, maintain unified versioning, and include proper test coverage.

Confidence Score: 5/5

  • This PR is safe to merge with no issues found
  • The implementation is clean, well-tested, and follows all project conventions. Timer cleanup and ResizeObserver cleanup are properly handled. The error handling issue from previous reviews has been resolved. All accessibility requirements are met with proper ARIA attributes. The changeset correctly includes all three packages following unified versioning rules.
  • No files require special attention

Important Files Changed

Filename Overview
packages/ui/src/components/animated-height.tsx New component for smooth height transitions using ResizeObserver and CSS transitions with motion-reduce support
packages/ui/src/components/bible-card.tsx Added delayed loading spinner (250ms) and AnimatedHeight wrapper to prevent UI flashing during refetch
packages/ui/src/components/verse.tsx Updated to show old content during refetch instead of spinner, with pulse animation and disabled interactions
packages/ui/src/components/icons/loader.tsx Added role="status" and aria-label="Loading" for accessibility

Last reviewed commit: fa2afe8

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.

10 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

}

if (currentError) {
if (currentError || fetchedError) {
Copy link
Contributor

Choose a reason for hiding this comment

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

checking both currentError and fetchedError causes stale error to show

when passageState is provided, currentError correctly uses passageState?.error, but fetchedError from the disabled hook could be stale from a previous fetch

Suggested change
if (currentError || fetchedError) {
if (currentError) {

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@greptileai, oddly enough, we want this to show versus the API-specific error that would be returned by fetchedError. So there is a generic error message that the user would get, but any developer could see in their logs the actual error. Because you have this context now, please re-review this pull request.

Copy link
Contributor

Choose a reason for hiding this comment

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

I understand you want the generic VerseUnavailableMessage shown to users - that makes sense for UX. My concern is specifically about this scenario:

  1. Component initially uses usePassage hook (no passageState provided)
  2. Fetch fails, fetchedError is set
  3. Parent then provides passageState with valid data and error: null
  4. Hook is now disabled (enabled: false) but retains fetchedError from step 2
  5. Your code checks if (currentError || fetchedError)
  6. Result: Shows error message even though passageState indicates success

Is this intentional? If you're always clearing the component when switching between controlled/uncontrolled states, this won't be an issue. But if you're hot-swapping the passageState prop, users might see an error when there shouldn't be one.

If this scenario can't happen in your app, the rest of the PR looks solid - the loading state optimization (&& !currentPassage) is excellent for preventing flicker.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, I hear ya. What would you suggest as a guarded preventative measure in the case the user/dev does hot-swap the passageState prop?

Copy link
Contributor

Choose a reason for hiding this comment

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

Just remove || fetchedError from the condition. You've already computed currentError correctly:

const currentError = hasProvidedPassageState ? (passageState?.error ?? null) : fetchedError;

So the check should be:

Suggested change
if (currentError || fetchedError) {
if (currentError) {

This mirrors your loading state logic - when passageState is provided, use its error; otherwise use the hook's error. No need to check both.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Does that mean we should just clear fetchedError if passageState changes? Because if there's an error fetching the Bible verse, then I WANT to show the generic error message component

Copy link
Contributor

Choose a reason for hiding this comment

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

I see the confusion. Let me clarify the scenario:

The issue isn't about hiding real errors - it's about stale errors from a previous state:

  1. Component starts uncontrolled → hook fetches → gets error → fetchedError is set
  2. Parent then provides passageState with valid data + error: null
  3. Hook is now disabled, but React Query retains fetchedError from step 1
  4. Your check if (currentError || fetchedError) shows the old error even though new state says success

You have two options:

Option 1: Trust the computed currentError (recommended)

if (currentError) {

This works because currentError already handles both cases correctly. When controlled, parent is responsible for passing errors via passageState.error.

Option 2: Reset the query when switching to controlled mode
If you're worried about edge cases, add a reset effect:

useEffect(() => {
  if (hasProvidedPassageState && fetchedError) {
    queryClient.resetQueries(['passage', reference, versionId]);
  }
}, [hasProvidedPassageState]);

But honestly, Option 1 is cleaner. If passageState is provided, the parent owns error handling. If there's a fetch error in controlled mode, it should come through passageState.error, not fetchedError.

Which approach do you prefer?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Okay fine. I relent. I did option 1. Turns out I was testing things in a way that people may not do in the real world.

@greptileai, can you re-review this entire PR now?

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice! The refactoring looks solid. Here's my full review:

✅ What's Working Well

Loading UX - The delayed spinner + showing old content during refetch is a huge improvement. No more jarring flashes.

AnimatedHeight component - Clean implementation with proper ResizeObserver cleanup and motion-reduce support.

Test coverage - Comprehensive tests for the delayed spinner and refetch behavior. Good job testing the edge cases.

Accessibility - LoaderIcon now has proper role="status" and ARIA labels.

Error handling - You fixed the currentError logic. It now correctly trusts the computed value instead of checking both. 👍


🔍 Minor Issues

packages/ui/src/components/animated-height.tsx:5,9
Your initial height is 0 until ResizeObserver fires. This means there's a brief moment where the wrapper has height: 0 before the effect runs, causing a jump from 0 → actual height on mount. Consider setting initial height from the ref:

const [height, setHeight] = useState<number | 'auto'>('auto');

useLayoutEffect(() => {
  const el = innerRef.current;
  if (!el || typeof ResizeObserver === 'undefined') {
    setHeight('auto'); // fallback for SSR
    return;
  }

  // Set initial height immediately
  setHeight(el.offsetHeight);

  const observer = new ResizeObserver(() => {
    if (el) setHeight(el.offsetHeight);
  });

  observer.observe(el);
  return () => observer.disconnect();
}, []);

packages/ui/src/components/bible-card.tsx:142
Using yv:h-9! with the important flag is a code smell. The ! suggests there's a specificity war happening. Can you achieve this without !important?

packages/ui/src/components/verse.tsx:413
You're applying animate-pulse to the wrapper. If the HTML content inside has its own animations (like fade-ins on footnote buttons), they might conflict. Test with content that has animations to make sure the pulse doesn't interfere.


Summary

The PR successfully fixes the FOUC/shifting issues. The error handling is now correct after your Option 1 fix. Ship it! 🚢

The AnimatedHeight initial height issue is the only thing I'd consider blocking, but it depends on whether you see the 0 → height jump in practice.

Adjusted the `BibleCard` component's styling to remove an unnecessary
gap between the header and the main content, and added a bottom margin
to the header section for better visual separation.
@cameronapak cameronapak self-assigned this Feb 27, 2026
Only render `VerseUnavailableMessage` if `currentError` exists.
Previously, it would also render if `fetchedError` existed, leading to
an incorrect UI state.
@cameronapak
Copy link
Collaborator Author

@greptileai, based on your latest review, please go and update your initial review. ty ty ty

@cameronapak cameronapak requested a review from bmanquen February 27, 2026 18:11
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.

1 participant