-
Notifications
You must be signed in to change notification settings - Fork 373
Pro RSC migration 3/3: React Server Components demo on webpack #729
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: ihabadham/feature/pro-rsc/base
Are you sure you want to change the base?
Changes from all commits
2424b1c
325f3e3
2f11ffe
1c4bef2
cf394ba
8e8c518
02b8b1c
b770daa
138befb
bc716e0
649e0bd
1ac1b27
fd9faf1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,6 +38,8 @@ def simple; end | |
|
|
||
| def rescript; end | ||
|
|
||
| def server_components; end | ||
|
|
||
| private | ||
|
|
||
| def set_comments | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,5 @@ | ||||||||||||
| <%= react_component("ServerComponentsPage", | ||||||||||||
| prerender: false, | ||||||||||||
| auto_load_bundle: true, | ||||||||||||
| trace: Rails.env.development?, | ||||||||||||
| id: "ServerComponentsPage-react-component-0") %> | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The explicit
Suggested change
|
||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,5 @@ | ||||||||||||||
| 'use client'; | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Marking this Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A file named Please add a short inline explanation, e.g.:
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| // Compare to ./RouterApp.client.jsx | ||||||||||||||
| import { Provider } from 'react-redux'; | ||||||||||||||
| import React from 'react'; | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,129 @@ | ||||||||||||||
| // Server Component - fetches comments directly from the Rails API on the server. | ||||||||||||||
| // Uses marked for markdown rendering. Both fetch and marked stay server-side. | ||||||||||||||
|
|
||||||||||||||
| import React from 'react'; | ||||||||||||||
| import { Marked } from 'marked'; | ||||||||||||||
| import { gfmHeadingId } from 'marked-gfm-heading-id'; | ||||||||||||||
| import sanitizeHtml from 'sanitize-html'; | ||||||||||||||
| import _ from 'lodash'; | ||||||||||||||
| import TogglePanel from './TogglePanel'; | ||||||||||||||
|
|
||||||||||||||
| const marked = new Marked(); | ||||||||||||||
| marked.use(gfmHeadingId()); | ||||||||||||||
|
|
||||||||||||||
| function resolveRailsBaseUrl() { | ||||||||||||||
| if (process.env.RAILS_INTERNAL_URL) { | ||||||||||||||
| return process.env.RAILS_INTERNAL_URL; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // Local defaults are okay in development/test, but production should be explicit. | ||||||||||||||
| if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { | ||||||||||||||
| return 'http://localhost:3000'; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| throw new Error('RAILS_INTERNAL_URL must be set outside development/test'); | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| async function CommentsFeed() { | ||||||||||||||
| // Simulate network latency only when explicitly enabled for demos. | ||||||||||||||
| if (process.env.RSC_SUSPENSE_DEMO_DELAY === 'true') { | ||||||||||||||
| await new Promise((resolve) => { | ||||||||||||||
| setTimeout(resolve, 800); | ||||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| let recentComments = []; | ||||||||||||||
| try { | ||||||||||||||
| // Fetch comments directly from the Rails API — no client-side fetch needed | ||||||||||||||
| const baseUrl = resolveRailsBaseUrl(); | ||||||||||||||
| const controller = new AbortController(); | ||||||||||||||
| const timeoutId = setTimeout(() => controller.abort(), 5000); | ||||||||||||||
| const response = await fetch(`${baseUrl}/comments.json`, { signal: controller.signal }); | ||||||||||||||
| clearTimeout(timeoutId); | ||||||||||||||
|
Comment on lines
+39
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(`${baseUrl}/comments.json`, { signal: controller.signal });
// ...
} finally {
clearTimeout(timeoutId);
} |
||||||||||||||
| if (!response.ok) { | ||||||||||||||
| throw new Error(`Failed to fetch comments: ${response.status} ${response.statusText}`); | ||||||||||||||
| } | ||||||||||||||
| const data = await response.json(); | ||||||||||||||
| const comments = data.comments; | ||||||||||||||
|
|
||||||||||||||
| // Use lodash to process (stays on server) | ||||||||||||||
| const sortedComments = _.orderBy(comments, ['created_at'], ['desc']); | ||||||||||||||
| recentComments = _.take(sortedComments, 10); | ||||||||||||||
| } catch (error) { | ||||||||||||||
| // eslint-disable-next-line no-console | ||||||||||||||
| console.error('CommentsFeed failed to load comments', error); | ||||||||||||||
| return ( | ||||||||||||||
| <div className="bg-rose-50 border border-rose-200 rounded-lg p-6 text-center"> | ||||||||||||||
| <p className="text-rose-700">Could not load comments right now. Please try again later.</p> | ||||||||||||||
| </div> | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (recentComments.length === 0) { | ||||||||||||||
| return ( | ||||||||||||||
| <div className="bg-amber-50 border border-amber-200 rounded-lg p-6 text-center"> | ||||||||||||||
| <p className="text-amber-700"> | ||||||||||||||
| No comments yet. Add some comments from the{' '} | ||||||||||||||
| <a href="/" className="underline font-medium"> | ||||||||||||||
| home page | ||||||||||||||
| </a>{' '} | ||||||||||||||
| to see them rendered here by server components. | ||||||||||||||
| </p> | ||||||||||||||
| </div> | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return ( | ||||||||||||||
| <div className="space-y-3"> | ||||||||||||||
| {recentComments.map((comment) => { | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
If allowedSchemesByTag: { img: ['https'] },
allowedSchemes: ['https', 'http'], |
||||||||||||||
| // Render markdown on the server using marked + sanitize-html. | ||||||||||||||
| // sanitize-html strips any dangerous HTML before rendering. | ||||||||||||||
| // These libraries (combined ~200KB) never reach the client. | ||||||||||||||
| const rawHtml = marked.parse(comment.text || ''); | ||||||||||||||
| const safeHtml = sanitizeHtml(rawHtml, { | ||||||||||||||
| allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']), | ||||||||||||||
| allowedAttributes: { | ||||||||||||||
| ...sanitizeHtml.defaults.allowedAttributes, | ||||||||||||||
| img: ['src', 'alt', 'title', 'width', 'height'], | ||||||||||||||
| }, | ||||||||||||||
| allowedSchemes: ['https', 'http'], | ||||||||||||||
| }); | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Comment on lines
+88
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| return ( | ||||||||||||||
| <div | ||||||||||||||
| key={comment.id} | ||||||||||||||
| className="bg-white border border-slate-200 rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow" | ||||||||||||||
| > | ||||||||||||||
| <div className="flex items-center justify-between mb-2"> | ||||||||||||||
| <span className="font-semibold text-slate-800">{comment.author}</span> | ||||||||||||||
| <span className="text-xs text-slate-400"> | ||||||||||||||
| {new Date(comment.created_at).toLocaleDateString('en-US', { | ||||||||||||||
| month: 'short', | ||||||||||||||
| day: 'numeric', | ||||||||||||||
| year: 'numeric', | ||||||||||||||
| hour: '2-digit', | ||||||||||||||
| minute: '2-digit', | ||||||||||||||
| })} | ||||||||||||||
| </span> | ||||||||||||||
| </div> | ||||||||||||||
| <TogglePanel title="Show rendered markdown"> | ||||||||||||||
| {/* Content is sanitized via sanitize-html before rendering */} | ||||||||||||||
| {/* eslint-disable-next-line react/no-danger */} | ||||||||||||||
| <div | ||||||||||||||
| className="prose prose-sm prose-slate max-w-none" | ||||||||||||||
| dangerouslySetInnerHTML={{ __html: safeHtml }} | ||||||||||||||
| /> | ||||||||||||||
| </TogglePanel> | ||||||||||||||
| <p className="text-slate-600 text-sm mt-1">{comment.text}</p> | ||||||||||||||
| </div> | ||||||||||||||
| ); | ||||||||||||||
| })} | ||||||||||||||
| <p className="text-xs text-slate-400 text-center pt-2"> | ||||||||||||||
| {recentComments.length} comment{recentComments.length !== 1 ? 's' : ''} rendered on the server using{' '} | ||||||||||||||
| <code>marked</code> + <code>sanitize-html</code> (never sent to browser) | ||||||||||||||
| </p> | ||||||||||||||
| </div> | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export default CommentsFeed; | ||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,58 @@ | ||||||
| // Server Component - uses Node.js os module, which only exists on the server. | ||||||
| // This component and its dependencies are never sent to the browser. | ||||||
|
|
||||||
| import React from 'react'; | ||||||
| import os from 'os'; | ||||||
| import _ from 'lodash'; | ||||||
|
|
||||||
| async function ServerInfo() { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| const serverInfo = { | ||||||
| platform: os.platform(), | ||||||
| arch: os.arch(), | ||||||
| nodeVersion: process.version, | ||||||
| uptime: Math.floor(os.uptime() / 3600), | ||||||
| totalMemory: (os.totalmem() / (1024 * 1024 * 1024)).toFixed(1), | ||||||
| freeMemory: (os.freemem() / (1024 * 1024 * 1024)).toFixed(1), | ||||||
| cpus: os.cpus().length, | ||||||
| hostname: os.hostname(), | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a demo that ships to a public review app, consider either gating the component behind |
||||||
| }; | ||||||
|
|
||||||
| // Using lodash on the server — this 70KB+ library stays server-side | ||||||
| const infoEntries = _.toPairs(serverInfo); | ||||||
| const grouped = _.chunk(infoEntries, 4); | ||||||
|
|
||||||
| const labels = { | ||||||
| platform: 'Platform', | ||||||
| arch: 'Architecture', | ||||||
| nodeVersion: 'Node.js', | ||||||
| uptime: 'Uptime (hrs)', | ||||||
| totalMemory: 'Total RAM (GB)', | ||||||
| freeMemory: 'Free RAM (GB)', | ||||||
| cpus: 'CPU Cores', | ||||||
| hostname: 'Hostname', | ||||||
| }; | ||||||
|
|
||||||
| return ( | ||||||
| <div className="bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-200 rounded-xl p-6"> | ||||||
| <p className="text-xs text-emerald-600 mb-4 font-medium"> | ||||||
| This data comes from the Node.js <code className="bg-emerald-100 px-1 rounded">os</code> module | ||||||
| — it runs only on the server. The <code className="bg-emerald-100 px-1 rounded">lodash</code> library | ||||||
| used to format it never reaches the browser. | ||||||
| </p> | ||||||
| <div className="grid md:grid-cols-2 gap-x-8 gap-y-1"> | ||||||
| {grouped.map((group) => ( | ||||||
| <div key={group.map(([k]) => k).join('-')} className="space-y-1"> | ||||||
| {group.map(([key, value]) => ( | ||||||
| <div key={key} className="flex justify-between py-1.5 border-b border-emerald-100 last:border-0"> | ||||||
| <span className="text-sm text-emerald-700 font-medium">{labels[key] || key}</span> | ||||||
| <span className="text-sm text-emerald-900 font-mono">{value}</span> | ||||||
| </div> | ||||||
| ))} | ||||||
| </div> | ||||||
| ))} | ||||||
| </div> | ||||||
| </div> | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| export default ServerInfo; | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| 'use client'; | ||
|
|
||
| import React, { useState } from 'react'; | ||
| import PropTypes from 'prop-types'; | ||
|
|
||
| const TogglePanel = ({ title, children }) => { | ||
| const [isOpen, setIsOpen] = useState(false); | ||
|
|
||
| return ( | ||
| <div className="border border-slate-200 rounded-lg overflow-hidden"> | ||
| <button | ||
| type="button" | ||
| onClick={() => setIsOpen((prev) => !prev)} | ||
| className="w-full flex items-center justify-between px-4 py-2.5 bg-slate-50 hover:bg-slate-100 transition-colors text-left" | ||
| > | ||
| <span className="text-sm font-medium text-slate-700">{title}</span> | ||
| <svg | ||
| className={`w-4 h-4 text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} | ||
| fill="none" | ||
| viewBox="0 0 24 24" | ||
| stroke="currentColor" | ||
| > | ||
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> | ||
| </svg> | ||
| </button> | ||
| {isOpen && ( | ||
| <div className="px-4 py-3 bg-white"> | ||
| {children} | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| TogglePanel.propTypes = { | ||
| title: PropTypes.string.isRequired, | ||
| children: PropTypes.node.isRequired, | ||
| }; | ||
|
|
||
| export default TogglePanel; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hardcoding the element
idis fragile — it will silently create duplicate IDs if the helper is ever called more than once (e.g. if this partial is rendered in a layout that includes it twice, or in a test that renders the view multiple times). React on Rails generates a unique ID automatically when none is provided; consider removing this option and letting the helper handle it: