Repository navigation
Remix 3 SSR: server-rendered, hydrated SPA for all HTML pages #601
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
Merged
Merged
Changes from 9 commits
Commits
Show all changes
37 commits
Select commit
Hold shift + click to select a range
5b39f2a
Add core SSR pipeline: renderToStream document, app-root clientEntry,…
cursoragent cbd90fe
Convert account, admin, and auth routes to SSR with shared loaders an…
cursoragent d8c9948
Add SSR perf layer (public data cache, per-request auth dedup, versio…
cursoragent 2a5ef2d
Fix same-origin fetches in account secrets (placeholder-origin URLs b…
cursoragent 03d69a4
Fix oxfmt formatting on ssr stub and admin-rbac strict-mode locator (…
cursoragent b7ecf6e
Bugbot fixes: scope SSR notFound to the rendered URL and silently rev…
cursoragent df85471
Address review feedback: gate hydration session refresh, guard-before…
cursoragent 11b6160
Fix token selection navigation to notify the router and guard communi…
cursoragent bbd116e
Render community grid whenever listings are ready to avoid blank grid…
cursoragent 8191cb0
Fix stale session cookie redirect loop
cursoragent 54f1338
Show loading state when route URL diverges from loaded data to avoid …
cursoragent 43f2238
Sync lastLoadedHref before navigate to avoid redundant token list ref…
cursoragent 8648468
Embed OAuth authorize info in SSR to fix loading state
cursoragent 52530dd
Fix OAuth authorize embed consumption on first render
cursoragent 6eddb97
Spike: Remix Frame SSR + reload inside hydrated app stack
cursoragent abce83f
Generalize Remix frame plumbing with registry and guardrails
cursoragent b111ebd
Extend frame registry to match parameterized route patterns
cursoragent df87bbd
Convert community index grid to a named frame
cursoragent 753805c
Convert community detail data region to a named frame
cursoragent ef72429
Add E2E coverage for community frame revalidation
cursoragent e8c5dc4
Make community detail frame E2E resilient to accumulated D1 state
cursoragent 9b05ff8
Remove frame-spike scratch route now that community frames are the ex…
cursoragent 2509562
Refresh session after username save so the nav updates immediately
cursoragent 7f4c310
Only apply immutable asset caching when URLs are versioned by a real …
cursoragent 1b9a5da
Pass through 304 asset revalidations instead of falling through to th…
cursoragent 23e7756
Retry E2E D1 commands on SQLITE_BUSY lock contention
cursoragent 653dd4e
Let the lint rule carry the frame-src enforcement instead of docs pro…
cursoragent b9945fd
Merge origin/main into cursor/remix-ssr-hydration-c537
cursoragent d932b1d
Merge branch 'cursor/remix-ssr-hydration-c537' into cursor/remix-fram…
cursoragent 05d6c33
Guard detail shell against stale render and refetch loops
cursoragent 6e7f63f
Dedupe in-flight shell fetches and revalidate shell with the frame
cursoragent ee3efac
Remix frames: server-rendered fragments with reload-based revalidatio…
kentcdodds d570a9c
Address Bugbot feedback: URL-driven token selection, dedupe loader da…
cursoragent 66b383b
Merge branch 'cursor/remix-frames-revalidation-c537' into cursor/remi…
cursoragent cdc2d9e
Merge remote-tracking branch 'origin/main' into cursor/remix-ssr-hydr…
cursoragent 53915ea
Guard admin route loads against duplicate in-flight fetches and error…
cursoragent 292cd61
Show loading shell immediately on listing change and gate shell rende…
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| import { expect, test } from '@playwright/test' | ||
| import { ensurePrimaryUserExists, primaryTestUser } from './auth-test-user.ts' | ||
| import { seedCommunityListingInE2eDatabase } from './d1-utils.ts' | ||
|
|
||
| const alphaListing = { | ||
| listingId: 'e2e-ssr-listing-alpha', | ||
| name: '@kody/alpha-ssr-package', | ||
| description: 'Alpha SSR fixture package for hydration tests.', | ||
| tags: ['alpha', 'ssr'], | ||
| } | ||
|
|
||
| const betaListing = { | ||
| listingId: 'e2e-ssr-listing-beta', | ||
| name: '@kody/beta-ssr-package', | ||
| description: 'Beta SSR fixture package for hydration tests.', | ||
| tags: ['beta', 'ssr'], | ||
| } | ||
|
|
||
| test('SSR community HTML hydrates SPA navigation and client search', async ({ | ||
| page, | ||
| request, | ||
| }) => { | ||
| await ensurePrimaryUserExists(request) | ||
| await seedCommunityListingInE2eDatabase({ | ||
| ...alphaListing, | ||
| ownerEmail: primaryTestUser.email, | ||
| }) | ||
| await seedCommunityListingInE2eDatabase({ | ||
| ...betaListing, | ||
| ownerEmail: primaryTestUser.email, | ||
| }) | ||
|
|
||
| const htmlResponse = await request.get('/community') | ||
| expect(htmlResponse.ok()).toBe(true) | ||
| const rawHtml = await htmlResponse.text() | ||
| expect(rawHtml).toContain(alphaListing.description) | ||
| expect(rawHtml).toContain(betaListing.name) | ||
| expect(rawHtml).not.toContain('Loading community packages') | ||
| expect(rawHtml).toContain('id="rmx-data"') | ||
| expect(rawHtml).toContain('"community"') | ||
|
|
||
| const filteredHtmlResponse = await request.get('/community?q=beta') | ||
| expect(filteredHtmlResponse.ok()).toBe(true) | ||
| const filteredHtml = await filteredHtmlResponse.text() | ||
| expect(filteredHtml).toContain(betaListing.description) | ||
| expect(filteredHtml).not.toContain(alphaListing.name) | ||
|
|
||
| await page.goto('/community') | ||
| await expect(page.getByText(alphaListing.description)).toBeVisible() | ||
| await expect(page.getByText(betaListing.description)).toBeVisible() | ||
|
|
||
| await page.evaluate(() => { | ||
| ;(window as Window & { __e2eMarker?: boolean }).__e2eMarker = true | ||
| }) | ||
|
|
||
| await page.getByRole('link', { name: alphaListing.name }).click() | ||
| await expect(page).toHaveURL( | ||
| new RegExp(`/community/${alphaListing.listingId}$`), | ||
| ) | ||
| await expect(page.getByText(alphaListing.description)).toBeVisible() | ||
| expect( | ||
| await page.evaluate( | ||
| () => (window as Window & { __e2eMarker?: boolean }).__e2eMarker, | ||
| ), | ||
| ).toBe(true) | ||
|
|
||
| await page.getByRole('link', { name: 'Community', exact: true }).click() | ||
| await expect(page).toHaveURL(/\/community$/) | ||
| expect( | ||
| await page.evaluate( | ||
| () => (window as Window & { __e2eMarker?: boolean }).__e2eMarker, | ||
| ), | ||
| ).toBe(true) | ||
|
|
||
| await page | ||
| .getByPlaceholder('Search by name, description, or tags') | ||
| .fill('beta') | ||
| await page.getByRole('button', { name: 'Search' }).click() | ||
| await expect(page).toHaveURL(/\/community\?q=beta/) | ||
| await expect(page.getByText(betaListing.description)).toBeVisible() | ||
| await expect(page.getByRole('link', { name: alphaListing.name })).toHaveCount( | ||
| 0, | ||
| ) | ||
| expect( | ||
| await page.evaluate( | ||
| () => (window as Window & { __e2eMarker?: boolean }).__e2eMarker, | ||
| ), | ||
| ).toBe(true) | ||
|
|
||
| await page.goto('/community?q=beta') | ||
| await expect( | ||
| page.getByPlaceholder('Search by name, description, or tags'), | ||
| ).toHaveValue('beta') | ||
| await expect(page.getByText(betaListing.description)).toBeVisible() | ||
| await expect(page.getByRole('link', { name: alphaListing.name })).toHaveCount( | ||
| 0, | ||
| ) | ||
|
|
||
| // A server-rendered 404 must not pin later SPA navigations to the | ||
| // not-found fallback. | ||
| const notFoundResponse = await page.goto('/definitely-not-a-page') | ||
| expect(notFoundResponse?.status()).toBe(404) | ||
| await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible() | ||
| await page.getByRole('link', { name: 'Community', exact: true }).click() | ||
| await expect(page).toHaveURL(/\/community$/) | ||
| await expect(page.getByText(betaListing.description)).toBeVisible() | ||
| await expect(page.getByRole('heading', { name: 'Not Found' })).toHaveCount(0) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { clientEntry, type EntryComponent, type Handle } from 'remix/ui' | ||
| import { App } from './app.tsx' | ||
| import { RouterLocationProvider } from './router-location.tsx' | ||
| import { type SessionInfo } from './session.ts' | ||
| import { type AppLoaderData } from '#client/loader-data-types.ts' | ||
|
|
||
| export const APP_ROOT_ENTRY_ID = '/client-entry.js#AppRoot' | ||
|
|
||
| export type AppRootProps = { | ||
| url: string | ||
| session: SessionInfo | null | ||
| loaderData?: AppLoaderData | ||
| notFound?: boolean | ||
| } | ||
|
|
||
| export const AppRoot: EntryComponent<AppRootProps> = clientEntry( | ||
| APP_ROOT_ENTRY_ID, | ||
| function AppRoot(handle: Handle<AppRootProps>) { | ||
| return () => ( | ||
| <RouterLocationProvider url={handle.props.url}> | ||
| <App | ||
| embeddedSession={handle.props.session} | ||
| loaderData={handle.props.loaderData} | ||
| notFound={handle.props.notFound === true} | ||
| /> | ||
| </RouterLocationProvider> | ||
| ) | ||
| }, | ||
| ) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.