Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,12 @@ dump.rdb

# Web Vitals measurement results
.vitals-results/

# Rails sprocket compiled assets
/public/assets

# Ad-hoc test/debug scripts
test-*.js

# MCP configuration
.mcp.json
70 changes: 70 additions & 0 deletions app/controllers/api/products_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# frozen_string_literal: true

module Api
class ProductsController < ApplicationController
skip_forgery_protection

# GET /api/products/:id/reviews
def reviews
product = Product.find(params[:id])
reviews = product.top_reviews(10)

render json: {
reviews: reviews.map { |r| serialize_review(r) },
timestamp: Time.current.iso8601
}
end

# GET /api/products/:id/review_stats
def review_stats
product = Product.find(params[:id])

render json: {
**product.review_stats,
timestamp: Time.current.iso8601
}
end

# GET /api/products/:id/related_products
def related_products
product = Product.find(params[:id])
related = product.related_products(4)

render json: {
products: related.map { |p| serialize_product_card(p) },
timestamp: Time.current.iso8601
}
end
Comment on lines +29 to +37
Copy link

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:

#!/bin/bash
# Find the Product model
fd -t f -name "*.rb" | xargs grep -l "class Product" | head -5

Repository: shakacode/react-server-components-marketplace-demo

Length of output: 324


🏁 Script executed:

#!/bin/bash
# Search for the related_products method definition
rg "def related_products" -A 10

Repository: shakacode/react-server-components-marketplace-demo

Length of output: 1386


🏁 Script executed:

#!/bin/bash
# Search for serialize_product_card method
rg "def serialize_product_card" -A 10

Repository: shakacode/react-server-components-marketplace-demo

Length of output: 1744


Potential N+1 query in related_products serialization.

The related_products method in the Product model doesn't eager-load the images association. Since serialize_product_card accesses product.images for each product, this will trigger a separate database query for each of the 4 related products. Add .includes(:images) to the query in the model method to preload associations:

def related_products(limit = 4)
  Product
    .where(category: category)
    .where.not(id: id)
    .order(average_rating: :desc, review_count: :desc)
    .includes(:images)
    .limit(limit)
end
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/controllers/api/products_controller.rb` around lines 29 - 37, The
related_products method on Product is causing an N+1 because
serialize_product_card reads product.images for each related product; update the
Product#related_products implementation to eager-load images (use
includes(:images)) so the query preloads images before limit is applied, keeping
the same filtering and ordering logic and the method name related_products to
locate the change.


private

def serialize_review(review)
{
id: review.id,
rating: review.rating,
title: review.title,
comment: review.comment,
reviewer_name: review.reviewer_name,
verified_purchase: review.verified_purchase,
helpful_count: review.helpful_count,
created_at: review.created_at.iso8601
}
end

def serialize_product_card(product)
{
id: product.id,
name: product.name,
price: product.price.to_f,
original_price: product.original_price&.to_f,
category: product.category,
brand: product.brand,
images: product.images,
average_rating: product.average_rating.to_f,
review_count: product.review_count,
in_stock: product.in_stock,
discount_percentage: product.discount_percentage
}
end
end
end
103 changes: 103 additions & 0 deletions app/controllers/products_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# frozen_string_literal: true

class ProductsController < ApplicationController
include ReactOnRailsPro::RSCPayloadRenderer
include ReactOnRailsPro::AsyncRendering

enable_async_react_rendering only: [:show_rsc]

# V1: Full Server SSR — fetch ALL data, return complete page
# All data must be ready before ANY HTML is sent to the browser.
def show_ssr
product = find_product

# Sequential queries — each one blocks the response
reviews = product.top_reviews(10)
review_stats = product.review_stats
related = product.related_products(4)

@product_data = serialize_product(product)
@reviews_data = reviews.map { |r| serialize_review(r) }
@review_stats_data = review_stats
@related_products_data = related.map { |p| serialize_product_card(p) }
end

# V2: Client Components — send basic product data, client fetches the rest
def show_client
@product_data = serialize_product(find_product)
end

# V3: RSC Streaming — shell streams immediately, heavy data streams as it resolves
def show_rsc
@product = find_product
# Exclude description/features/specs from initial props — they stream via product_details
# async prop to keep the initial shell small and prioritize LCP.
@product_data = serialize_product(@product).except(:description, :features, :specs)
stream_view_containing_react_components(template: "products/show_rsc")
end

private

def hero_image_url
@product_data&.dig(:images, 0, "url") || @product_data&.dig(:images, 0, :url)
end
helper_method :hero_image_url

def find_product
if params[:id]
Product.find(params[:id])
else
Product.first!
end
end

def serialize_product(product)
{
id: product.id,
name: product.name,
description: product.description,
price: product.price.to_f,
original_price: product.original_price&.to_f,
category: product.category,
brand: product.brand,
sku: product.sku,
images: product.images,
specs: product.specs,
features: product.features,
average_rating: product.average_rating.to_f,
review_count: product.review_count,
stock_quantity: product.stock_quantity,
in_stock: product.in_stock,
discount_percentage: product.discount_percentage
}
end

def serialize_review(review)
{
id: review.id,
rating: review.rating,
title: review.title,
comment: review.comment,
reviewer_name: review.reviewer_name,
verified_purchase: review.verified_purchase,
helpful_count: review.helpful_count,
created_at: review.created_at.iso8601
}
end

def serialize_product_card(product)
{
id: product.id,
name: product.name,
price: product.price.to_f,
original_price: product.original_price&.to_f,
category: product.category,
brand: product.brand,
images: product.images,
average_rating: product.average_rating.to_f,
review_count: product.review_count,
in_stock: product.in_stock,
discount_percentage: product.discount_percentage
}
end
end
122 changes: 122 additions & 0 deletions app/javascript/components/product/AddToCartSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
'use client';

import React, { useState, useCallback } from 'react';

interface Props {
price: number;
inStock: boolean;
stockQuantity: number;
}

export function AddToCartSection({ price, inStock, stockQuantity }: Props) {
const [quantity, setQuantity] = useState(1);
const [addedToCart, setAddedToCart] = useState(false);

const handleDecrement = useCallback(() => {
setQuantity((q) => Math.max(1, q - 1));
}, []);

const handleIncrement = useCallback(() => {
setQuantity((q) => Math.min(stockQuantity, q + 1));
}, [stockQuantity]);

const handleAddToCart = useCallback(() => {
setAddedToCart(true);
setTimeout(() => setAddedToCart(false), 2000);
}, []);
Comment on lines +23 to +26
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential memory leak: setTimeout not cleared on unmount.

If the component unmounts before the 2-second timeout completes, setAddedToCart(false) will attempt to update state on an unmounted component. Use useEffect cleanup or a ref to track mount status.

🛠️ Proposed fix using useEffect cleanup
+import React, { useState, useCallback, useEffect, useRef } from 'react';
 
 // Inside the component:
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  useEffect(() => {
+    return () => {
+      if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    };
+  }, []);
+
   const handleAddToCart = useCallback(() => {
     setAddedToCart(true);
-    setTimeout(() => setAddedToCart(false), 2000);
+    timeoutRef.current = setTimeout(() => setAddedToCart(false), 2000);
   }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/components/product/AddToCartSection.tsx` around lines 23 - 26,
The handleAddToCart callback sets a 2s timeout via setTimeout but never clears
it, risking setAddedToCart(false) running after unmount; modify AddToCartSection
to store the timeout ID (via a ref like timeoutRef) when calling setTimeout in
handleAddToCart and add a useEffect cleanup that clears the timeout
(clearTimeout(timeoutRef.current)) on unmount, or alternatively track mounted
state in a ref and check it before calling setAddedToCart(false); ensure
references to handleAddToCart and setAddedToCart remain consistent and the
timeout ID is cleared to prevent the memory leak.


return (
<div className="space-y-4">
{/* Quantity selector */}
<div className="flex items-center gap-4">
<span className="text-sm font-medium text-gray-700">Quantity</span>
<div className="flex items-center border border-gray-300 rounded-lg">
<button
onClick={handleDecrement}
disabled={quantity <= 1}
className="w-10 h-10 flex items-center justify-center text-gray-600 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-l-lg"
aria-label="Decrease quantity"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" d="M5 12h14" />
</svg>
</button>
<span className="w-12 text-center text-sm font-medium tabular-nums">{quantity}</span>
<button
onClick={handleIncrement}
disabled={quantity >= stockQuantity}
className="w-10 h-10 flex items-center justify-center text-gray-600 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors rounded-r-lg"
aria-label="Increase quantity"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" d="M12 5v14m-7-7h14" />
</svg>
</button>
</div>
</div>

{/* Add to Cart & Buy Now buttons */}
<div className="flex gap-3">
<button
onClick={handleAddToCart}
disabled={!inStock}
className={`flex-1 py-3.5 px-6 rounded-xl text-sm font-semibold transition-all ${
addedToCart
? 'bg-green-600 text-white'
: inStock
? 'bg-indigo-600 text-white hover:bg-indigo-700 active:scale-[0.98]'
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
}`}
>
{addedToCart ? (
<span className="flex items-center justify-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Added to Cart
</span>
) : (
`Add to Cart — $${(price * quantity).toFixed(2)}`
)}
</button>
<button
disabled={!inStock}
className="py-3.5 px-6 rounded-xl text-sm font-semibold border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50 active:scale-[0.98] transition-all disabled:opacity-30 disabled:cursor-not-allowed"
>
Buy Now
</button>
</div>

{/* Stock & shipping info */}
<div className="flex flex-col gap-2 pt-2">
{inStock ? (
<div className="flex items-center gap-2 text-sm text-green-700">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
In Stock ({stockQuantity} available)
</div>
) : (
<div className="flex items-center gap-2 text-sm text-red-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
Out of Stock
</div>
)}
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
Free shipping on orders over $50
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
30-day hassle-free returns
</div>
</div>
</div>
);
}
84 changes: 84 additions & 0 deletions app/javascript/components/product/AsyncProductContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use client';

// Client-side fetcher for V2 (Client Components version).
// Fetches reviews, review stats, and related products via API calls.
// All libraries (date-fns, marked, highlight.js) are loaded client-side.

import React, { useState, useEffect } from 'react';
import { Product, ProductReview, ReviewStats, ProductCard } from '../../types/product';
import { ProductDescription } from './ProductDescription';
import { ProductFeatures } from './ProductFeatures';
import { ProductSpecs } from './ProductSpecs';
import { ReviewDistributionChart } from './ReviewDistributionChart';
import { ReviewsList } from './ReviewsList';
import { RelatedProducts } from './RelatedProducts';
import { ReviewStatsSkeleton, ReviewsSkeleton, RelatedProductsSkeleton } from './ProductSkeletons';

interface Props {
product: Product;
}

export default function AsyncProductContent({ product }: Props) {
const [reviewStats, setReviewStats] = useState<ReviewStats | null>(null);
const [reviews, setReviews] = useState<ProductReview[] | null>(null);
const [relatedProducts, setRelatedProducts] = useState<ProductCard[] | null>(null);

useEffect(() => {
const controller = new AbortController();
const opts = { signal: controller.signal };

fetch(`/api/products/${product.id}/review_stats`, opts)
.then((r) => r.json())
.then(setReviewStats)
.catch(() => {});

fetch(`/api/products/${product.id}/reviews`, opts)
.then((r) => r.json())
.then((data) => setReviews(data.reviews))
.catch(() => {});

fetch(`/api/products/${product.id}/related_products`, opts)
.then((r) => r.json())
.then((data) => setRelatedProducts(data.products))
.catch(() => {});
Comment on lines +30 to +43
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Silent error handling leaves users with infinite loading state.

The empty .catch(() => {}) blocks swallow all errors, including network failures and server errors. If any fetch fails, the corresponding section shows a skeleton forever with no user feedback.

Additionally, there's no response.ok check before parsing JSON—4xx/5xx responses may have non-JSON bodies or error structures that won't match expected types.

🐛 Proposed fix with error handling
+  const [error, setError] = useState<string | null>(null);

   useEffect(() => {
     const controller = new AbortController();
     const opts = { signal: controller.signal };

+    const handleFetch = async <T,>(url: string, setter: (data: T) => void, transform?: (data: any) => T) => {
+      try {
+        const r = await fetch(url, opts);
+        if (!r.ok) throw new Error(`HTTP ${r.status}`);
+        const data = await r.json();
+        setter(transform ? transform(data) : data);
+      } catch (e) {
+        if (e instanceof Error && e.name !== 'AbortError') {
+          setError('Failed to load some content');
+        }
+      }
+    };

-    fetch(`/api/products/${product.id}/review_stats`, opts)
-      .then((r) => r.json())
-      .then(setReviewStats)
-      .catch(() => {});
+    handleFetch(`/api/products/${product.id}/review_stats`, setReviewStats);

-    fetch(`/api/products/${product.id}/reviews`, opts)
-      .then((r) => r.json())
-      .then((data) => setReviews(data.reviews))
-      .catch(() => {});
+    handleFetch(`/api/products/${product.id}/reviews`, setReviews, (d) => d.reviews);

-    fetch(`/api/products/${product.id}/related_products`, opts)
-      .then((r) => r.json())
-      .then((data) => setRelatedProducts(data.products))
-      .catch(() => {});
+    handleFetch(`/api/products/${product.id}/related_products`, setRelatedProducts, (d) => d.products);

     return () => controller.abort();
   }, [product.id]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/javascript/components/product/AsyncProductContent.tsx` around lines 30 -
43, The three fetch calls (for review_stats, reviews, related_products)
currently parse JSON without checking response.ok and swallow errors via empty
.catch, causing perpetual skeletons; update each fetch to first check
response.ok (e.g., fetch(...).then(r => { if (!r.ok) throw new
Error(`${r.status} ${r.statusText}`); return r.json(); }) ), then in the .then
handlers call setReviewStats / setReviews / setRelatedProducts as before, and in
the .catch handlers do meaningful error handling: console.error the error and
set sensible fallbacks (e.g., setReviewStats(null) or empty objects/arrays)
and/or flip any loading flags (use setXLoading if present) so the UI stops
showing skeletons; keep opts usage unchanged and apply the same pattern to all
three fetches.


return () => controller.abort();
}, [product.id]);

return (
<>
{/* Product description (uses marked + highlight.js) */}
<ProductDescription description={product.description} />

{/* Features */}
<ProductFeatures features={product.features} />

{/* Specifications */}
<ProductSpecs specs={product.specs} />

{/* Reviews section */}
<section className="border-t border-gray-200 pt-8 mt-8">
<h2 className="text-xl font-bold text-gray-900 mb-6">Customer Reviews</h2>
{reviewStats ? (
<ReviewDistributionChart
distribution={reviewStats.distribution}
averageRating={reviewStats.average_rating}
totalReviews={reviewStats.total_reviews}
/>
) : (
<ReviewStatsSkeleton />
)}
<div className="mt-8">
{reviews ? <ReviewsList reviews={reviews} /> : <ReviewsSkeleton />}
</div>
</section>

{/* Related products */}
{relatedProducts ? (
<RelatedProducts products={relatedProducts} />
) : (
<RelatedProductsSkeleton />
)}
</>
);
}
Loading
Loading