Skip to content
Draft
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@
"@ember/routing/router-service.js": "ember-source/@ember/routing/router-service.js",
"@ember/routing/router.js": "ember-source/@ember/routing/router.js",
"@ember/runloop/index.js": "ember-source/@ember/runloop/index.js",
"@ember/server-rendering/index.js": "ember-source/@ember/server-rendering/index.js",
"@ember/service/index.js": "ember-source/@ember/service/index.js",
"@ember/template-compilation/index.js": "ember-source/@ember/template-compilation/index.js",
"@ember/template-compiler/-internal-primitives.js": "ember-source/@ember/template-compiler/-internal-primitives.js",
Expand Down Expand Up @@ -347,7 +348,6 @@
"@simple-dom/document/index.js": "ember-source/@simple-dom/document/index.js",
"backburner.js/index.js": "ember-source/backburner.js/index.js",
"dag-map/index.js": "ember-source/dag-map/index.js",
"ember-template-compiler/index.js": "ember-source/ember-template-compiler/index.js",
"ember-testing/index.js": "ember-source/ember-testing/index.js",
"ember-testing/lib/adapters/adapter.js": "ember-source/ember-testing/lib/adapters/adapter.js",
"ember-testing/lib/adapters/qunit.js": "ember-source/ember-testing/lib/adapters/qunit.js",
Expand Down
118 changes: 118 additions & 0 deletions packages/@ember/server-rendering/index.js
Copy link
Contributor

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

Copy link
Contributor Author

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:

  1. async/await — banned by disable-features/disable-async-await; replaced with .then() chains
  2. const inside functions — banned by ember-internal/no-const-outside-module-scope; replaced with let
  3. globalThis — not in the ESLint globals for packages/**/*.js; replaced with window (which is declared) since entry-server.js already polyfills window = globalThis in Node
  4. JSDoc format — the ember-internal/require-yuidoc-access rule expects @public/@private without a leading * per line (Ember's YUIDoc style); switched to that format
  5. Also renamed patchQuerySelectorAllpatchSimpleDocument for clarity

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 };
});
}
15 changes: 15 additions & 0 deletions packages/@ember/server-rendering/package.json
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"
}
}
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions smoke-tests/node-template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"test:node": "qunit tests/node/**/*-test.js"
},
"dependencies": {
"ember-source": "workspace:*",
"git-repo-info": "^2.1.1",
"html-differ": "^1.4.0",
"qunit": "^2.20.1",
Expand Down
86 changes: 86 additions & 0 deletions smoke-tests/node-template/tests/node/ssr-test.js
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');
});
});
});
15 changes: 14 additions & 1 deletion smoke-tests/v2-app-template/app/config/environment.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
import loadConfigFromMeta from '@embroider/config-meta-loader';
import { assert } from '@ember/debug';

const config = loadConfigFromMeta('v2-app-template');
// In SSR (Node.js) context there is no DOM to read meta tags from,
// so we provide a default configuration that works for server-side rendering.
// The locationType is set to 'none' since there is no browser history API.
const config =
typeof document !== 'undefined'
? loadConfigFromMeta('v2-app-template')
: {
modulePrefix: 'v2-app-template',
podModulePrefix: '',
environment: 'production',
rootURL: '/',
locationType: 'none',
APP: {},
};

assert(
'config is not an object',
Expand Down
44 changes: 25 additions & 19 deletions smoke-tests/v2-app-template/app/deprecation-workflow.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,28 @@ import setupDeprecationWorkflow from 'ember-cli-deprecation-workflow';
/**
* Docs: https://github.com/ember-cli/ember-cli-deprecation-workflow
*/
setupDeprecationWorkflow({
/**
false by default, but if a developer / team wants to be more aggressive about being proactive with
handling their deprecations, this should be set to "true"
*/
throwOnUnhandled: false,
workflow: [
/* ... handlers ... */
/* to generate this list, run your app for a while (or run the test suite),
* and then run in the browser console:
*
* deprecationWorkflow.flushDeprecations()
*
* And copy the handlers here
*/
/* example: */
/* { handler: 'silence', matchId: 'template-action' }, */
],
});

// Only run the deprecation workflow in browser environments.
// In SSR (Node.js) context, browser globals like `self` are unavailable.
if (typeof document !== 'undefined') {
setupDeprecationWorkflow({
/**
false by default, but if a developer / team wants to be more aggressive about being proactive with
handling their deprecations, this should be set to "true"
*/
throwOnUnhandled: false,
workflow: [
/* ... handlers ... */
/* to generate this list, run your app for a while (or run the test suite),
* and then run in the browser console:
*
* deprecationWorkflow.flushDeprecations()
*
* And copy the handlers here
*/
/* example: */
/* { handler: 'silence', matchId: 'template-action' }, */
],
});
}

28 changes: 28 additions & 0 deletions smoke-tests/v2-app-template/entry-client.js
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);
}
Loading
Loading