From 365dba933e2a6fe5bc166bd86f2a2c882a8b9ed7 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 13 May 2026 22:47:09 -0400 Subject: [PATCH 1/3] Pre-render docs-app pages with vite-ember-ssr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set up SSG for the docs-app so all 60 routes (landing page + markdown docs) emit static HTML at build time, with Glimmer rehydration on the client. The non-obvious bits: - inlineDynamicImports is needed for the SSR build, otherwise rolldown splits Ember into chunks that circular-import @ember/object's ComputedProperty before it initialises. - A polyfill module isn't early enough — rolldown inlines our app modules after vendor inits, so the globals (Worker, postMessage, localStorage, isSecureContext, Text, getComputedStyle, the Popover API) are shimmed via rollupOptions.output.banner instead. - content-tag, @babel/standalone, and lorem-ipsum are stubbed in the SSR graph: content-tag and @babel/standalone are bundled via ember-repl's known-modules map but are only used at runtime for the live REPL, and lorem-ipsum is CJS whose named exports rolldown can't extract during SSR builds. - setupTabster is skipped under import.meta.env.SSR since it touches window/document.body at setup time. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs-app/app/app-ssr.ts | 34 +++++++++ docs-app/app/boot.ts | 14 ++++ docs-app/app/routes/application.ts | 2 +- docs-app/index.html | 9 +-- docs-app/package.json | 3 +- docs-app/vite.config.mjs | 108 ++++++++++++++++++++++++++++- pnpm-lock.yaml | 57 +++++++++++++++ 7 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 docs-app/app/app-ssr.ts create mode 100644 docs-app/app/boot.ts diff --git a/docs-app/app/app-ssr.ts b/docs-app/app/app-ssr.ts new file mode 100644 index 000000000..4ae1c805f --- /dev/null +++ b/docs-app/app/app-ssr.ts @@ -0,0 +1,34 @@ +import { settled } from '@ember/test-helpers'; + +import PageTitleService from 'ember-page-title/services/page-title'; +import Application from 'ember-strict-application-resolver'; + +import Router from './router.ts'; + +export default class SsrApp extends Application { + modules = { + ...import.meta.glob('./router.ts', { eager: true }), + ...import.meta.glob('./templates/**/*.{gjs,gts,md}', { eager: true }), + ...import.meta.glob('./routes/**/*.{gjs,gts,js,ts,md}', { eager: true }), + './router': Router, + './services/page-title': PageTitleService, + }; +} + +export function createSsrApp() { + const app = SsrApp.create({ autoboot: false }); + + const originalVisit = app.visit.bind(app); + + Object.assign(app, { + visit: async (...args: Parameters) => { + const instance = await originalVisit(...args); + + await settled(); + + return instance; + }, + }); + + return app; +} diff --git a/docs-app/app/boot.ts b/docs-app/app/boot.ts new file mode 100644 index 000000000..2b50b8949 --- /dev/null +++ b/docs-app/app/boot.ts @@ -0,0 +1,14 @@ +import { shouldRehydrate } from 'vite-ember-ssr/client'; + +import Application from './app.ts'; +import environment from './config/environment.ts'; + +if (shouldRehydrate()) { + const app = Application.create({ ...environment.APP, autoboot: false }); + + void app.visit(window.location.pathname + window.location.search, { + _renderMode: 'rehydrate', + }); +} else { + Application.create(environment.APP); +} diff --git a/docs-app/app/routes/application.ts b/docs-app/app/routes/application.ts index 90677cacf..28c5c560a 100644 --- a/docs-app/app/routes/application.ts +++ b/docs-app/app/routes/application.ts @@ -33,7 +33,7 @@ export default class Application extends Route { }); await Promise.all([ - setupTabster(this), + import.meta.env?.SSR ? Promise.resolve() : setupTabster(this), setupKolay(this, { // This won't work, because the compiler can't find the element to rendedr in to. // remarkPlugins: [ diff --git a/docs-app/index.html b/docs-app/index.html index 3003a4016..bbc1df1fa 100644 --- a/docs-app/index.html +++ b/docs-app/index.html @@ -13,13 +13,10 @@ as="font" rel="font/woff2" /> + - + + diff --git a/docs-app/package.json b/docs-app/package.json index 73d19ce9f..32cac8394 100644 --- a/docs-app/package.json +++ b/docs-app/package.json @@ -112,7 +112,8 @@ "testem": "^3.17.0", "typedoc": "^0.28.15", "typescript": "^6.0.2", - "vite": "^8.0.3" + "vite": "^8.0.3", + "vite-ember-ssr": "^0.1.0" }, "engines": { "node": ">= 20" diff --git a/docs-app/vite.config.mjs b/docs-app/vite.config.mjs index cd2dbf56f..b33c786c4 100644 --- a/docs-app/vite.config.mjs +++ b/docs-app/vite.config.mjs @@ -2,13 +2,111 @@ import { ember, extensions } from "@embroider/vite"; import { babel } from "@rollup/plugin-babel"; import { kolay } from "kolay/vite"; +import { globby } from "globby"; import { defineConfig } from "vite"; import { scopedCSS } from "ember-scoped-css/vite"; +import { emberSsg } from "vite-ember-ssr/vite-plugin"; import rehypeShiki from "@shikijs/rehype"; -export default defineConfig(async (/* { mode } */) => { +/** + * Replace runtime-only deps with empty stubs during SSR. Pre-rendering + * never exercises the REPL's runtime template compilation, but bundling + * content-tag/babel-standalone into the SSR build trips up rolldown + * (and isn't useful even if it built). + */ +function stubRuntimeOnlyDepsForSsr() { + const stubs = new Map([ + ["content-tag", "export class Preprocessor {}; export default { Preprocessor };"], + ["@babel/standalone", "export default {};"], + /** + * Imported via named export by demo snippets in markdown samples. + * lorem-ipsum is CJS and rolldown can't extract named exports from + * CJS during SSR builds, so substitute an ESM stub. The real package + * loads client-side after rehydration. + */ + [ + "lorem-ipsum", + 'export const loremIpsum = () => ""; export class LoremIpsum { generateSentences() { return ""; } generateParagraphs() { return ""; } generateWords() { return ""; } } export default { loremIpsum, LoremIpsum };', + ], + ]); + return { + name: "docs-app:stub-runtime-only-deps-for-ssr", + enforce: "pre", + resolveId(id, _importer, options) { + if (!options?.ssr) return; + if (stubs.has(id)) return `\0virtual:ssr-stub:${id}`; + }, + load(id) { + const prefix = "\0virtual:ssr-stub:"; + if (!id.startsWith(prefix)) return; + return stubs.get(id.slice(prefix.length)); + }, + }; +} + +export default defineConfig(async ({ isSsrBuild } = {}) => { + const docsRoutes = (await globby("app/templates/**/*.gjs.md", { cwd: import.meta.dirname })) + .map((p) => p.replace(/^app\/templates\//, "").replace(/\.gjs\.md$/, ".md")) + .sort(); + + return { + /** + * Force a single SSR bundle. Without this, rolldown splits Ember + * into chunks that circular-import `@ember/object` internals — the + * `ComputedProperty` class isn't initialized by the time + * `proxy.js`'s top-level `computed()` calls run. + * + * The banner installs no-op shims for globals that downstream + * modules read while loading (repl-sdk constructs a `new Worker(...)` + * at module-eval; @embroider/macros expects `process.env`). Rolldown + * inlines our app's module bodies after vendor inits, so a polyfill + * import isn't early enough — the banner runs before the first + * statement of the bundle. + */ + build: isSsrBuild + ? { + rollupOptions: { + output: { + inlineDynamicImports: true, + banner: [ + "globalThis.process ??= { env: {} };", + "globalThis.Buffer ??= {};", + "globalThis.Worker ??= class { postMessage(){} terminate(){} addEventListener(){} removeEventListener(){} };", + /** + * repl-sdk captures browser-only globals into a + * `standardScope` map at module init. Provide minimal + * shims so the capture step doesn't throw under Node. + */ + "globalThis.postMessage ??= () => {};", + "globalThis.localStorage ??= { getItem(){return null}, setItem(){}, removeItem(){}, clear(){}, key(){return null}, length: 0 };", + "globalThis.sessionStorage ??= globalThis.localStorage;", + "globalThis.isSecureContext ??= false;", + /** + * happy-dom installs `window` but not its DOM + * interfaces or layout APIs on globalThis. A few demos + * touch them at module init. + */ + "if (globalThis.window) {", + " globalThis.Text ??= globalThis.window.Text;", + " globalThis.getComputedStyle ??= globalThis.window.getComputedStyle?.bind(globalThis.window);", + /** + * happy-dom lacks the Popover API. Stub it so demos + * that call showPopover() on render don't throw. + */ + " if (globalThis.window.HTMLElement) {", + " globalThis.window.HTMLElement.prototype.showPopover ??= function() {};", + " globalThis.window.HTMLElement.prototype.hidePopover ??= function() {};", + " globalThis.window.HTMLElement.prototype.togglePopover ??= function() {};", + " }", + "}", + ].join("\n"), + }, + }, + } + : {}, plugins: [ + stubRuntimeOnlyDepsForSsr(), scopedCSS(), ember(), kolay({ @@ -42,7 +140,15 @@ export default defineConfig(async (/* { mode } */) => { babelHelpers: "runtime", extensions, }), + emberSsg({ + routes: ["index", ...docsRoutes], + ssrEntry: "app/app-ssr.ts", + rehydrate: true, + }), ], + ssr: { + noExternal: [/./], + }, optimizeDeps: { exclude: [ // a wasm-providing dependency diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9f5e1725..1216eb913 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,9 @@ importers: vite: specifier: ^8.0.3 version: 8.0.10(d985db06b97e1f56cc68cb1e11366df8) + vite-ember-ssr: + specifier: ^0.1.0 + version: 0.1.0(14d69d31e7b7879e941bb8c997a05f7e) ember-primitives: dependencies: @@ -2627,6 +2630,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -4039,6 +4045,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -4695,6 +4705,10 @@ packages: engines: {node: '>=0.4.7'} hasBin: true + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -7006,6 +7020,10 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} @@ -7311,6 +7329,12 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-ember-ssr@0.1.0: + resolution: {integrity: sha512-5AD9mvznfkNApNxWqMgshnzERGOARrcT6eKSbIKwtmgLV58NBhv2bFSr+P5e3BrbabQRGdWjNU92Wix3k9p5mA==} + engines: {node: '>=22'} + peerDependencies: + vite: ^6.0.0 || ^7.0.0 + vite@8.0.10: resolution: {integrity: sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7438,6 +7462,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -9953,6 +9981,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.6.0 @@ -11607,6 +11637,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + env-paths@2.2.1: {} environment@1.1.0: {} @@ -12484,6 +12516,18 @@ snapshots: optionalDependencies: uglify-js: 3.19.3 + happy-dom@20.9.0: + dependencies: + '@types/node': 25.6.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-bigints@1.1.0: {} has-flag@3.0.0: {} @@ -15377,6 +15421,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@2.1.0: {} + tldts-core@6.1.86: {} tldts@6.1.86: @@ -15737,6 +15783,15 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-ember-ssr@0.1.0(14d69d31e7b7879e941bb8c997a05f7e): + dependencies: + happy-dom: 20.9.0 + tinypool: 2.1.0 + vite: 8.0.10(d985db06b97e1f56cc68cb1e11366df8) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + vite@8.0.10(d985db06b97e1f56cc68cb1e11366df8): dependencies: lightningcss: 1.32.0 @@ -15845,6 +15900,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} whatwg-url@14.2.0: From a62b3f963bf4b2ea8f9d25856f510cbf3c74e20e Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 13 May 2026 23:45:07 -0400 Subject: [PATCH 2/3] Move settled() to a per-route timeout to unblock docs-tests CI The previous app.visit wrapper awaited settled() with no timeout, which hung forever in CI on routes whose demos use ReactiveImage (avatar.md). ReactiveImage wraps its load promise in waitForPromise(), but happy-dom never fires onload/onerror on `` elements, so the waiter stays pending and settled() never resolves. Locally Node 24 finished the build (presumably because of slightly different happy-dom resource behavior), but in CI on Node 22 the worker hung past the 5-minute job timeout. Two-part fix: - Export `settled` from app-ssr.ts and drop the inner await. The vite-ember-ssr worker calls awaitSettled(settledTimeout) (default 10s) after app.visit returns, so an unresponsive route degrades to "capture the DOM anyway after 10s" instead of hanging the build. - Synthesise `onload` for `` src setters in the SSR banner so ReactiveImage's waitForPromise resolves on the next microtask. With this, the avatar demo actually pre-renders the `` tags (data-loading=false branch) instead of only the Fallback path, and no route hits the 10s timeout on the happy path. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs-app/app/app-ssr.ts | 27 ++++++++++----------------- docs-app/vite.config.mjs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/docs-app/app/app-ssr.ts b/docs-app/app/app-ssr.ts index 4ae1c805f..4a7090ebd 100644 --- a/docs-app/app/app-ssr.ts +++ b/docs-app/app/app-ssr.ts @@ -1,5 +1,3 @@ -import { settled } from '@ember/test-helpers'; - import PageTitleService from 'ember-page-title/services/page-title'; import Application from 'ember-strict-application-resolver'; @@ -15,20 +13,15 @@ export default class SsrApp extends Application { }; } -export function createSsrApp() { - const app = SsrApp.create({ autoboot: false }); - - const originalVisit = app.visit.bind(app); - - Object.assign(app, { - visit: async (...args: Parameters) => { - const instance = await originalVisit(...args); +/** + * Exported so vite-ember-ssr's worker can await it with a timeout per + * render (`settledTimeout`, default 10s). Demos with `ReactiveImage` + * register `waitForPromise` waiters that never resolve under Node + * (happy-dom doesn't fire `` onload), so an unbounded `settled()` + * inside `app.visit` would hang the whole render forever. + */ +export { settled } from '@ember/test-helpers'; - await settled(); - - return instance; - }, - }); - - return app; +export function createSsrApp() { + return SsrApp.create({ autoboot: false }); } diff --git a/docs-app/vite.config.mjs b/docs-app/vite.config.mjs index b33c786c4..6b4de2307 100644 --- a/docs-app/vite.config.mjs +++ b/docs-app/vite.config.mjs @@ -99,6 +99,25 @@ export default defineConfig(async ({ isSsrBuild } = {}) => { " globalThis.window.HTMLElement.prototype.hidePopover ??= function() {};", " globalThis.window.HTMLElement.prototype.togglePopover ??= function() {};", " }", + /** + * happy-dom never fires onload/onerror on `` + * elements (no resource fetching). Make src setter + * synthesise an onload microtask so `ReactiveImage`'s + * waitForPromise waiter resolves and settled() can + * complete without hitting the per-route timeout. + */ + " const HTMLImageElement = globalThis.window.HTMLImageElement;", + " if (HTMLImageElement) {", + " const desc = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src');", + " Object.defineProperty(HTMLImageElement.prototype, 'src', {", + " configurable: true,", + " get() { return desc?.get ? desc.get.call(this) : this.getAttribute('src'); },", + " set(v) {", + " if (desc?.set) desc.set.call(this, v); else this.setAttribute('src', v);", + " queueMicrotask(() => { try { this.onload?.(new Event('load')); } catch {} });", + " },", + " });", + " }", "}", ].join("\n"), }, From f6aa8e058231e358a034b151938efc68ac8ecb95 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 13 May 2026 23:48:56 -0400 Subject: [PATCH 3/3] Use codeSplitting: false instead of deprecated inlineDynamicImports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolldown deprecated `inlineDynamicImports` in favour of `codeSplitting: false`. Both have the same effect — one bundle, no chunk graph, no TDZ on Ember internals — but the new spelling avoids the deprecation warning in the build output. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs-app/vite.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-app/vite.config.mjs b/docs-app/vite.config.mjs index 6b4de2307..7fb544b66 100644 --- a/docs-app/vite.config.mjs +++ b/docs-app/vite.config.mjs @@ -68,7 +68,7 @@ export default defineConfig(async ({ isSsrBuild } = {}) => { ? { rollupOptions: { output: { - inlineDynamicImports: true, + codeSplitting: false, banner: [ "globalThis.process ??= { env: {} };", "globalThis.Buffer ??= {};",