-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Add SSR/SSG rendering support for Ember Vite apps #21201
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
Draft
Copilot
wants to merge
4
commits into
main
Choose a base branch
from
copilot/implement-ssr-rendering
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| /** | ||
| @module @ember/server-rendering | ||
| @public | ||
| */ | ||
|
|
||
| import createHTMLDocument from '@simple-dom/document'; | ||
| import HTMLSerializer from '@simple-dom/serializer'; | ||
| import voidMap from '@simple-dom/void-map'; | ||
|
|
||
| // The glimmer serialization markers are HTML comments used during rehydration. | ||
| // When pre-rendering (SSG), we strip these to produce clean static HTML. | ||
| const GLIMMER_COMMENT_PATTERN = /<!--%[^%]*%-->/g; | ||
|
|
||
| // Patches a SimpleDOM node tree with minimal selector stubs so that | ||
| // SSR-unaware addons that call `querySelector`/`querySelectorAll` during | ||
| // render don't throw. SimpleDOM does not implement CSS selectors. | ||
| function patchSimpleDocument(node) { | ||
| if (node && typeof node === 'object' && !node.querySelectorAll) { | ||
| node.querySelectorAll = function () { | ||
| return { length: 0 }; | ||
| }; | ||
| node.querySelector = function () { | ||
| return null; | ||
| }; | ||
| let child = node.firstChild; | ||
| while (child) { | ||
| patchSimpleDocument(child); | ||
| child = child.nextSibling; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| Renders an Ember application to an HTML string suitable for server-side | ||
| rendering (SSR). The output includes Glimmer rehydration markers so the | ||
| client can efficiently rehydrate the server-rendered DOM rather than | ||
| re-rendering from scratch. | ||
|
|
||
| This is compatible with Vite's SSR mode. Use this in your `entry-server.js` | ||
| to render the app on the server, then use `_renderMode: 'rehydrate'` on the | ||
| client to rehydrate the result. | ||
|
|
||
| @method renderToHTML | ||
| @for @ember/server-rendering | ||
| @param {string} url The URL path to render (e.g. '/', '/about') | ||
| @param {object} AppClass Your Ember Application subclass | ||
| @return {Promise} resolves to `{ html }` with the serialized body HTML | ||
| @public | ||
| */ | ||
| export function renderToHTML(url, AppClass) { | ||
| let ssrDocument = createHTMLDocument(); | ||
| let rootElement = ssrDocument.body; | ||
|
|
||
| // Patch the SimpleDOM nodes with minimal selector stubs so that | ||
| // SSR-unaware addons (e.g. ember-page-title in development mode) that | ||
| // call `document.head.querySelectorAll()` don't throw. | ||
| patchSimpleDocument(ssrDocument); | ||
|
|
||
| // Some addons access the global `document` directly rather than via Ember's | ||
| // DI container. Set it to our SimpleDOM instance during rendering so those | ||
| // accesses don't throw in Node.js, then restore the previous value. | ||
| let previousDocument = window.document; | ||
| window.document = ssrDocument; | ||
|
|
||
| let app = AppClass.create({ autoboot: false }); | ||
|
|
||
| function cleanup() { | ||
| if (previousDocument === undefined) { | ||
| delete window.document; | ||
| } else { | ||
| window.document = previousDocument; | ||
| } | ||
| } | ||
|
|
||
| return app | ||
| .visit(url, { | ||
| isBrowser: false, | ||
| document: ssrDocument, | ||
| rootElement: rootElement, | ||
| _renderMode: 'serialize', | ||
| }) | ||
| .then( | ||
| function (instance) { | ||
| let serializer = new HTMLSerializer(voidMap); | ||
| let html = serializer.serializeChildren(rootElement); | ||
| instance.destroy(); | ||
| app.destroy(); | ||
| cleanup(); | ||
| return { html }; | ||
| }, | ||
| function (err) { | ||
| app.destroy(); | ||
| cleanup(); | ||
| throw err; | ||
| } | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| Pre-renders an Ember application to a clean HTML string for static site | ||
| generation (SSG). Unlike `renderToHTML`, the output does NOT include | ||
| Glimmer rehydration markers, making it suitable for documentation sites | ||
| or any scenario where the client performs a full render. | ||
|
|
||
| @method prerender | ||
| @for @ember/server-rendering | ||
| @param {string} url The URL path to render (e.g. '/', '/about') | ||
| @param {object} AppClass Your Ember Application subclass | ||
| @return {Promise} resolves to `{ html }` with clean static HTML | ||
| @public | ||
| */ | ||
| export function prerender(url, AppClass) { | ||
| return renderToHTML(url, AppClass).then(function (result) { | ||
| // Strip Glimmer's serialization markers (block boundaries, etc.) | ||
| let html = result.html.replace(GLIMMER_COMMENT_PATTERN, ''); | ||
| return { html }; | ||
| }); | ||
| } |
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,15 @@ | ||
| { | ||
| "name": "@ember/server-rendering", | ||
| "private": true, | ||
| "type": "module", | ||
| "exports": { | ||
| ".": "./index.js" | ||
| }, | ||
| "dependencies": { | ||
| "@ember/application": "workspace:*", | ||
| "@glimmer/node": "workspace:*", | ||
| "@simple-dom/document": "^1.4.0", | ||
| "@simple-dom/serializer": "^1.4.0", | ||
| "@simple-dom/void-map": "^1.4.0" | ||
| } | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
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,86 @@ | ||
| const SimpleDOM = require('simple-dom'); | ||
| const setupAppTest = require('./helpers/setup-app'); | ||
|
|
||
| let htmlSerializer = new SimpleDOM.HTMLSerializer(SimpleDOM.voidMap); | ||
|
|
||
| // Renders using _renderMode: 'serialize' which produces Glimmer rehydration | ||
| // markers in the HTML output (used for SSR with client-side rehydration). | ||
| function ssrVisit(App, url) { | ||
| let doc = new SimpleDOM.Document(); | ||
| let rootElement = doc.body; | ||
|
|
||
| return App.visit(url, { | ||
| isBrowser: false, | ||
| document: doc, | ||
| rootElement: rootElement, | ||
| _renderMode: 'serialize', | ||
| }).then(function (instance) { | ||
| try { | ||
| return htmlSerializer.serialize(rootElement); | ||
| } finally { | ||
| instance.destroy(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| QUnit.module('SSR - _renderMode serialize', function (hooks) { | ||
| setupAppTest(hooks); | ||
|
|
||
| QUnit.test('renders HTML with Glimmer rehydration markers', function (assert) { | ||
| this.template('application', '<h1>Hello SSR</h1>{{outlet}}'); | ||
|
|
||
| let App = this.createApplication(); | ||
|
|
||
| return ssrVisit(App, '/').then(function (html) { | ||
| assert.ok(html.includes('Hello SSR'), 'rendered content is present'); | ||
| // Glimmer serialization markers are HTML comments of the form <!--%...%--> | ||
| assert.ok(/<!--%/.test(html), 'Glimmer rehydration markers are present in SSR output'); | ||
| }); | ||
| }); | ||
|
|
||
| QUnit.test('renders route content with rehydration markers', function (assert) { | ||
| this.routes(function () { | ||
| this.route('about'); | ||
| }); | ||
|
|
||
| this.template('application', '<div id="app">{{outlet}}</div>'); | ||
| this.template('about', '<h2>About Page</h2>'); | ||
|
|
||
| let App = this.createApplication(); | ||
|
|
||
| return ssrVisit(App, '/about').then(function (html) { | ||
| assert.ok(html.includes('About Page'), 'route content is rendered'); | ||
| assert.ok(/<!--%/.test(html), 'Glimmer rehydration markers are present'); | ||
| }); | ||
| }); | ||
|
|
||
| QUnit.test('SSR output includes rehydration markers not present in FastBoot output', function (assert) { | ||
| this.template('application', '<h1>Hello world</h1>'); | ||
|
|
||
| let App = this.createApplication(); | ||
| let doc = new SimpleDOM.Document(); | ||
| let rootElement = doc.body; | ||
|
|
||
| let plainVisit = App.visit('/', { | ||
| isBrowser: false, | ||
| document: doc, | ||
| rootElement: rootElement, | ||
| }).then(function (instance) { | ||
| try { | ||
| return htmlSerializer.serialize(rootElement); | ||
| } finally { | ||
| instance.destroy(); | ||
| } | ||
| }); | ||
|
|
||
| let ssrVisitPromise = ssrVisit(App, '/'); | ||
|
|
||
| return Promise.all([plainVisit, ssrVisitPromise]).then(function (results) { | ||
| let plainHTML = results[0]; | ||
| let ssrHTML = results[1]; | ||
|
|
||
| assert.notOk(/<!--%/.test(plainHTML), 'plain FastBoot output has no rehydration markers'); | ||
| assert.ok(/<!--%/.test(ssrHTML), 'SSR output contains rehydration markers'); | ||
| }); | ||
| }); | ||
| }); |
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,28 @@ | ||
| /** | ||
| * Vite SSR client entry point for the v2-app-template. | ||
| * | ||
| * When the page was server-rendered (SSR mode), this boots the Ember | ||
| * application with `_renderMode: 'rehydrate'` so Glimmer reuses the | ||
| * existing server-rendered DOM nodes instead of clearing and re-rendering. | ||
| * | ||
| * When the page was NOT server-rendered (CSR/development mode), this boots | ||
| * normally via the standard `Application.create()` autoboot path. | ||
| * | ||
| * @see entry-server.js for the server-side rendering counterpart | ||
| */ | ||
| import Application from './app/app.js'; | ||
| import config from './app/config/environment.js'; | ||
|
|
||
| const isSSR = document.body.hasAttribute('data-ember-ssr'); | ||
|
|
||
| if (isSSR) { | ||
| // The page was server-rendered; rehydrate the existing DOM. | ||
| const app = Application.create({ ...config.APP, autoboot: false }); | ||
| app.visit('/', { | ||
| _renderMode: 'rehydrate', | ||
| rootElement: document.body, | ||
| }); | ||
| } else { | ||
| // Normal client-side rendering. | ||
| Application.create(config.APP); | ||
| } |
Oops, something went wrong.
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.
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.
lol what is going on here
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.
Fixed in 4e852be. The issues were:
async/await— banned bydisable-features/disable-async-await; replaced with.then()chainsconstinside functions — banned byember-internal/no-const-outside-module-scope; replaced withletglobalThis— not in the ESLint globals forpackages/**/*.js; replaced withwindow(which is declared) sinceentry-server.jsalready polyfillswindow = globalThisin Nodeember-internal/require-yuidoc-accessrule expects@public/@privatewithout a leading*per line (Ember's YUIDoc style); switched to that formatpatchQuerySelectorAll→patchSimpleDocumentfor clarity