Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
3118725
Add preliminary rehydration setup
backspace Jan 19, 2026
8c1c628
Merge remote-tracking branch 'origin/main' into host/rehydration-cs-9977
backspace Jan 20, 2026
5f64bae
Add more hacks
backspace Jan 20, 2026
f6d87a2
Merge remote-tracking branch 'origin/main' into host/rehydration-cs-9977
backspace Jan 20, 2026
ef0aea2
Add note about possible future fix
backspace Jan 20, 2026
e616705
Change back to original delimiters
backspace Jan 20, 2026
8fe8991
Update test to exercise rehydration
backspace Jan 21, 2026
92a9e99
Change back to Fastboot delimiters again
backspace Jan 21, 2026
62f157c
Add autoformat fix
backspace Jan 21, 2026
477b227
Add render mode initialisation script
backspace Jan 21, 2026
2b7eeea
Change prerender to include Glimmer comments
backspace Jan 21, 2026
ee66484
Add cleanup of spurious elements
backspace Jan 21, 2026
bea98b3
Update test name and add explanation
backspace Jan 21, 2026
4c212ed
Merge remote-tracking branch 'origin/main' into host/rehydration-cs-9977
backspace Jan 22, 2026
687a8f5
Merge branch 'main' into host/rehydration-cs-9977
backspace Feb 9, 2026
c24096e
Move what was in patch into initialiser
backspace Feb 9, 2026
ba29e33
Fix conditional setting of render mode
backspace Feb 9, 2026
815d67c
Restore delimiter matches
backspace Feb 9, 2026
ddf6d74
Restore support for serialise mode
backspace Feb 9, 2026
98c10fe
Add assertion that head injection is only head
backspace Feb 9, 2026
8e27add
Fix import for serializeBuilder
backspace Feb 9, 2026
b30de81
Fix capture of head elements
backspace Feb 9, 2026
33b6ea7
Fix version of @glimmer/node
backspace Feb 9, 2026
ca59626
Add more logging for prerendering errors
backspace Feb 9, 2026
c87abd8
Add inline SerializeBuilder
backspace Feb 9, 2026
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
5 changes: 2 additions & 3 deletions packages/host/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,10 @@

<meta data-boxel-head-start />
<meta data-boxel-head-end />

</head>
<body>
<script type="x/boundary" id="boxel-isolated-start"></script>
<script type="x/boundary" id="boxel-isolated-end"></script>
<script type="x/boundary" id="fastboot-body-start"></script>
<script type="x/boundary" id="fastboot-body-end"></script>

<!-- in case embercli's hooks insn't run,
we embed the following div manually -->
Expand Down
142 changes: 142 additions & 0 deletions packages/host/app/initializers/experimental-rehydrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type Application from '@ember/application';

// @ts-expect-error - glimmer internals not typed for direct import
import { clientBuilder, rehydrationBuilder } from '@glimmer/runtime';
// @ts-expect-error - glimmer internals not typed for direct import
import { ConcreteBounds, NewElementBuilder } from '@glimmer/runtime';

declare const FastBoot: unknown;

// Inlined from @glimmer/node to avoid pulling in a second copy of
// @glimmer/runtime (and its transitive @glimmer/global-context) which
// would cause webpack to bundle two uninitialised copies and break at
// runtime with "scheduleDestroyed is not a function".
const NEEDS_EXTRA_CLOSE = new WeakMap();

class SerializeBuilder extends (NewElementBuilder as any) {
serializeBlockDepth = 0;

__openBlock() {
let { tagName } = this.element;
if (tagName !== 'TITLE' && tagName !== 'SCRIPT' && tagName !== 'STYLE') {
let depth = this.serializeBlockDepth++;
this.__appendComment(`%+b:${depth}%`);
}
super.__openBlock();
}

__closeBlock() {
let { tagName } = this.element;
super.__closeBlock();
if (tagName !== 'TITLE' && tagName !== 'SCRIPT' && tagName !== 'STYLE') {
let depth = --this.serializeBlockDepth;
this.__appendComment(`%-b:${depth}%`);
}
}

__appendHTML(html: string) {
let { tagName } = this.element;
if (tagName === 'TITLE' || tagName === 'SCRIPT' || tagName === 'STYLE') {
return super.__appendHTML(html);
}
let first = this.__appendComment('%glmr%');
if (tagName === 'TABLE') {
let openIndex = html.indexOf('<');
if (openIndex > -1 && html.slice(openIndex + 1, openIndex + 3) === 'tr') {
html = `<tbody>${html}</tbody>`;
}
}
if (html === '') {
this.__appendComment('% %');
} else {
super.__appendHTML(html);
}
let last = this.__appendComment('%glmr%');
return new (ConcreteBounds as any)(this.element, first, last);
}

__appendText(string: string) {
let { tagName } = this.element;
let current = ((): any => {
let { element, nextSibling } = this as any;
return nextSibling === null ? element.lastChild : nextSibling.previousSibling;
})();
if (tagName === 'TITLE' || tagName === 'SCRIPT' || tagName === 'STYLE') {
return super.__appendText(string);
}
if (string === '') {
return this.__appendComment('% %');
}
if (current && current.nodeType === 3) {
this.__appendComment('%|%');
}
return super.__appendText(string);
}

closeElement() {
if (NEEDS_EXTRA_CLOSE.has(this.element)) {
NEEDS_EXTRA_CLOSE.delete(this.element);
super.closeElement();
}
return super.closeElement();
}

openElement(tag: string) {
if (
tag === 'tr' &&
this.element.tagName !== 'TBODY' &&
this.element.tagName !== 'THEAD' &&
this.element.tagName !== 'TFOOT'
) {
this.openElement('tbody');
NEEDS_EXTRA_CLOSE.set(this.constructing, true);
this.flushElement(null);
}
return super.openElement(tag);
}

pushRemoteElement(element: any, cursorId: string, insertBefore: any = null) {
let { dom } = this as any;
let script = dom.createElement('script');
script.setAttribute('glmr', cursorId);
dom.insertBefore(element, script, insertBefore);
return super.pushRemoteElement(element, cursorId, insertBefore);
}
}

function serializeBuilder(env: any, cursor: any) {
return SerializeBuilder.forInitialRender(env, cursor);
}

export function initialize(application: Application): void {
// Don't override in FastBoot (server-side) — let Ember's default serialize mode work
if (typeof FastBoot !== 'undefined') {
return;
}

application.register('service:-dom-builder', {
create() {
if (
typeof document !== 'undefined' &&
// @ts-expect-error hmm
globalThis.__boxelRenderMode === 'rehydrate'
) {
console.log('[ember-host] Boxel render mode override: rehydrate');
return rehydrationBuilder.bind(null);
} else if (
typeof document !== 'undefined' &&
// @ts-expect-error what to do
globalThis.__boxelRenderMode === 'serialize'
) {
console.log('[ember-host] Boxel render mode override: serialize');
return serializeBuilder.bind(null);
} else {
return clientBuilder.bind(null);
}
},
});
}

export default {
initialize,
};
7 changes: 4 additions & 3 deletions packages/host/app/templates/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,8 @@ export class IndexComponent extends Component<IndexComponentComponentSignature>
if (typeof document === 'undefined') {
return;
}
let start = document.getElementById('boxel-isolated-start');
let end = document.getElementById('boxel-isolated-end');
let start = document.getElementById('fastboot-body-start');
let end = document.getElementById('fastboot-body-end');
if (!start || !end) {
return;
}
Expand All @@ -232,7 +232,8 @@ export class IndexComponent extends Component<IndexComponentComponentSignature>
@stackItemCardIds={{this.hostModeStateService.stackItems}}
@removeCardFromStack={{this.removeCardFromStack}}
@viewCard={{this.viewCard}}
{{this.removeIsolatedMarkup}}
class='host-mode-content'
{{!-- {{this.removeIsolatedMarkup}} --}}
/>
{{/if}}
{{else}}
Expand Down
30 changes: 29 additions & 1 deletion packages/matrix/tests/host-mode.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,29 @@ test.describe('Host mode', () => {
'host-mode-isolated-card.gts',
`
import { CardDef, Component } from 'https://cardstack.com/base/card-api';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';

export class HostModeIsolatedCard extends CardDef {
static isolated = class Isolated extends Component<typeof this> {
@tracked showExtra = false;

addExtra = () => {
this.showExtra = true;
};

<template>
<p data-test-host-mode-isolated>Host mode isolated</p>
<button
type="button"
data-test-host-mode-button
{{on 'click' this.addExtra}}
>
Add extra
</button>
{{#if this.showExtra}}
<div data-test-host-mode-extra>Extra content</div>
{{/if}}
</template>
};
}
Expand Down Expand Up @@ -182,7 +200,7 @@ test.describe('Host mode', () => {
await logout(page);
});

test('published card response includes isolated template markup', async ({
test('published card response includes isolated template markup that’s rehydrated when Ember takes over', async ({
page,
}) => {
let html = await waitUntil(async () => {
Expand All @@ -202,6 +220,16 @@ test.describe('Host mode', () => {

await page.goto(publishedCardURL);
await expect(page.locator('[data-test-host-mode-isolated]')).toBeVisible();

let button = page.locator('[data-test-host-mode-button]');
await expect(button).toBeVisible();

// Click the button in the template until the Ember event handler responds and shows the extra element
await waitUntil(async () => {
await button.click();
return await page.locator('[data-test-host-mode-extra]').isVisible();
});
await expect(page.locator('[data-test-host-mode-extra]')).toBeVisible();
});

test('printed isolated card produces a stable page count', async ({
Expand Down
14 changes: 13 additions & 1 deletion packages/realm-server/lib/index-html-injection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,19 @@ export function injectIsolatedHTML(
isolatedHTML: string,
): string {
return indexHTML.replace(
/(<script[^>]+id="boxel-isolated-start"[^>]*>\s*<\/script>)([\s\S]*?)(<script[^>]+id="boxel-isolated-end"[^>]*>\s*<\/script>)/,
/(<script[^>]+id="fastboot-body-start"[^>]*>\s*<\/script>)([\s\S]*?)(<script[^>]+id="fastboot-body-end"[^>]*>\s*<\/script>)/,
(_match, start, _content, end) => `${start}\n${isolatedHTML}\n${end}`,
);
}

export function injectRenderModeScript(indexHTML: string): string {
let script = `<script>globalThis.__boxelRenderMode = 'rehydrate';</script>`;
let updated = indexHTML.replace(
/(<meta[^>]+data-boxel-head-end[^>]*>)/,
`$1\n${script}`,
);
if (updated === indexHTML) {
return indexHTML.replace(/<\/head>/i, `${script}\n</head>`);
}
return updated;
}
45 changes: 45 additions & 0 deletions packages/realm-server/prerender/page-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,37 @@ export class PagePool {
let browser = await this.#browserManager.getBrowser();
context = await browser.createBrowserContext();
let page = await context.newPage();

page.on('pageerror', (err) => log.error(`pageerror ${pageId}:`, err));
page.on('error', (err) => log.error(`error ${pageId}:`, err));
page.on('requestfailed', (req) =>
log.warn(
`requestfailed ${pageId}: ${req.url()} ${req.failure()?.errorText}`,
),
);
page.on('response', (res) => {
if (res.status() >= 400) {
log.warn(`response ${pageId}: ${res.status()} ${res.url()}`);
}
});

let pageId = uuidv4();
this.#attachPageConsole(page, 'standby', pageId);
log.debug(`Created standby page ${pageId}`);
await page.evaluateOnNewDocument(
'window.__boxelRenderMode = "serialize";',
);
await page.evaluateOnNewDocument(`
window.addEventListener('error', (e) => {
console.error('[prerender-error-capture]', e.message, e.filename + ':' + e.lineno + ':' + e.colno, e.error?.stack || '');
});
window.addEventListener('unhandledrejection', (e) => {
let reason = e.reason;
let msg = reason instanceof Error ? reason.stack || reason.message : String(reason);
console.error('[prerender-unhandled-rejection]', msg);
});
`);

await this.#loadStandbyPage(page, pageId);
let entry: StandbyEntry = {
type: 'standby',
Expand Down Expand Up @@ -713,6 +742,22 @@ export class PagePool {
if (typeof value === 'undefined') {
return arg.toString();
}
// Error objects serialize to {} via JSON — extract message+stack instead
if (
typeof value === 'object' &&
value !== null &&
Object.keys(value).length === 0
) {
let errorInfo = await arg.evaluate((obj: any) => {
if (obj instanceof Error) {
return `${obj.name}: ${obj.message}\n${obj.stack ?? ''}`;
}
return undefined;
}).catch(() => undefined);
if (errorInfo) {
return errorInfo;
}
}
return JSON.stringify(value);
} catch (_e) {
return arg.toString();
Expand Down
2 changes: 1 addition & 1 deletion packages/realm-server/prerender/remote-prerenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export function createRemotePrerenderer(
if (e?.name === 'AbortError') {
// AbortError from request timeout—consider this a hard timeout, not a retryable deployment blip.
throw new Error(
`Prerender request to ${endpoint.href} aborted after ${requestTimeoutMs}ms`,
`${new Date()} Prerender request to ${endpoint.href} aborted after ${requestTimeoutMs}ms`,
);
}
let retryable =
Expand Down
Loading
Loading