-
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
9e17d03
df039e2
f2d0d3d
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 | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,6 @@ | ||||||||||||||||
| <%= stream_react_component("ServerComponentsPage", | ||||||||||||||||
| props: { comments: @server_components_comments }, | ||||||||||||||||
| prerender: true, | ||||||||||||||||
| auto_load_bundle: true, | ||||||||||||||||
| trace: Rails.env.development?, | ||||||||||||||||
|
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. Hardcoding the element
Suggested change
|
||||||||||||||||
| 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
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
|
||||||||||||||||
| 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,84 @@ | ||||||||||||||
| import React from 'react'; | ||||||||||||||
| import { Marked } from 'marked'; | ||||||||||||||
| import { gfmHeadingId } from 'marked-gfm-heading-id'; | ||||||||||||||
| import sanitizeHtml from 'sanitize-html'; | ||||||||||||||
| import TogglePanel from './TogglePanel'; | ||||||||||||||
|
|
||||||||||||||
| const marked = new Marked(); | ||||||||||||||
| marked.use(gfmHeadingId()); | ||||||||||||||
|
|
||||||||||||||
| // Default-on small delay so the surrounding <Suspense> fallback is visible | ||||||||||||||
| // in the demo. Set RSC_SUSPENSE_DEMO_DELAY=false to disable (CI / tests). | ||||||||||||||
| async function CommentsFeed({ comments = [] }) { | ||||||||||||||
| if (process.env.RSC_SUSPENSE_DEMO_DELAY !== 'false') { | ||||||||||||||
|
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 guard is inverted — this delay fires in every environment unless
Suggested change
Or gate on if (process.env.NODE_ENV !== 'production' && process.env.RSC_SUSPENSE_DEMO_DELAY !== 'false') { |
||||||||||||||
| await new Promise((resolve) => { | ||||||||||||||
| setTimeout(resolve, 800); | ||||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if (comments.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"> | ||||||||||||||
| {comments.map((comment) => { | ||||||||||||||
| // marked + sanitize-html (~200KB combined) stay server-side. | ||||||||||||||
| 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. Including
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.
Suggested change
Comment on lines
+43
to
+45
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"> | ||||||||||||||
| {comments.length} comment{comments.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,76 @@ | ||
| 'use client'; | ||
|
|
||
| import React, { useState } from 'react'; | ||
| import { ErrorBoundary } from 'react-error-boundary'; | ||
| import RSCRoute from 'react-on-rails-pro/RSCRoute'; | ||
| import { useRSC } from 'react-on-rails-pro/RSCProvider'; | ||
|
|
||
| const LiveActivityRefresher = () => { | ||
| const [refreshKey, setRefreshKey] = useState(0); | ||
| const [simulateError, setSimulateError] = useState(false); | ||
| const { refetchComponent } = useRSC(); | ||
|
|
||
| const handleRefresh = () => { | ||
| setSimulateError(false); | ||
| setRefreshKey((k) => k + 1); | ||
| }; | ||
|
|
||
| const handleSimulateError = () => { | ||
| setSimulateError(true); | ||
| setRefreshKey((k) => k + 1); | ||
| }; | ||
|
|
||
| // refetchComponent primes the cache with corrected props before resetting | ||
| // the boundary, so the post-reset render hits cache instead of re-fetching. | ||
| const buildRetry = (resetErrorBoundary) => () => { | ||
| const newKey = refreshKey + 1; | ||
| setSimulateError(false); | ||
| setRefreshKey(newKey); | ||
| refetchComponent('LiveActivity', { simulateError: false, refreshKey: newKey }) | ||
| // eslint-disable-next-line no-console | ||
| .catch((err) => console.error('Retry refetch failed:', err)) | ||
| .finally(() => resetErrorBoundary()); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="space-y-3"> | ||
| <div className="flex items-center gap-2"> | ||
| <button | ||
| type="button" | ||
| onClick={handleRefresh} | ||
| className="px-3 py-1.5 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700" | ||
| > | ||
| Refresh | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={handleSimulateError} | ||
| className="px-3 py-1.5 text-sm bg-amber-100 text-amber-800 border border-amber-300 rounded hover:bg-amber-200" | ||
| > | ||
| Simulate Error | ||
| </button> | ||
| <span className="text-xs text-slate-500 ml-2">Refresh count: {refreshKey}</span> | ||
| </div> | ||
| <ErrorBoundary | ||
| fallbackRender={({ error, resetErrorBoundary }) => ( | ||
| <div className="bg-rose-50 border border-rose-200 rounded-lg p-4"> | ||
| <p className="text-rose-700 font-semibold mb-1">Server component fetch failed</p> | ||
| <p className="text-rose-600 text-sm font-mono mb-3">{error.message}</p> | ||
| <button | ||
| type="button" | ||
| onClick={buildRetry(resetErrorBoundary)} | ||
| className="px-3 py-1.5 text-sm bg-rose-600 text-white rounded hover:bg-rose-700" | ||
| > | ||
| Retry | ||
| </button> | ||
| </div> | ||
| )} | ||
| resetKeys={[refreshKey]} | ||
| > | ||
| <RSCRoute componentName="LiveActivity" componentProps={{ simulateError, refreshKey }} /> | ||
| </ErrorBoundary> | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export default LiveActivityRefresher; |
| 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'; | ||||||
|
|
||||||
| function ServerInfo() { | ||||||
| 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.
server_componentsalready fetches the latest 10 comments, but this action still runs the controller-widebefore_action :set_comments, which loads the full comments relation on every/server-componentsrequest and is never used here. As the comments table grows, that extra query/allocation becomes a real latency and memory hit for this page; scopingset_commentsto the actions that need@comments(or skipping it for this action) avoids the regression.Useful? React with 👍 / 👎.