diff --git a/packages/host/app/index.html b/packages/host/app/index.html index cdb36deefa..acbe01a0e2 100644 --- a/packages/host/app/index.html +++ b/packages/host/app/index.html @@ -31,11 +31,10 @@ - - - + + diff --git a/packages/host/app/initializers/experimental-rehydrate.ts b/packages/host/app/initializers/experimental-rehydrate.ts new file mode 100644 index 0000000000..e5328aff17 --- /dev/null +++ b/packages/host/app/initializers/experimental-rehydrate.ts @@ -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 = `${html}`; + } + } + 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, +}; diff --git a/packages/host/app/templates/index.gts b/packages/host/app/templates/index.gts index 8a8a60d7fd..3a7f5e74fe 100644 --- a/packages/host/app/templates/index.gts +++ b/packages/host/app/templates/index.gts @@ -204,8 +204,8 @@ export class IndexComponent extends Component 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; } @@ -232,7 +232,8 @@ export class IndexComponent extends Component @stackItemCardIds={{this.hostModeStateService.stackItems}} @removeCardFromStack={{this.removeCardFromStack}} @viewCard={{this.viewCard}} - {{this.removeIsolatedMarkup}} + class='host-mode-content' + {{!-- {{this.removeIsolatedMarkup}} --}} /> {{/if}} {{else}} diff --git a/packages/matrix/tests/host-mode.spec.ts b/packages/matrix/tests/host-mode.spec.ts index eb450c0c0b..f39bf37615 100644 --- a/packages/matrix/tests/host-mode.spec.ts +++ b/packages/matrix/tests/host-mode.spec.ts @@ -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 { + @tracked showExtra = false; + + addExtra = () => { + this.showExtra = true; + }; + }; } @@ -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 () => { @@ -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 ({ diff --git a/packages/realm-server/lib/index-html-injection.ts b/packages/realm-server/lib/index-html-injection.ts index 6d61f0fefe..af11675f62 100644 --- a/packages/realm-server/lib/index-html-injection.ts +++ b/packages/realm-server/lib/index-html-injection.ts @@ -126,7 +126,19 @@ export function injectIsolatedHTML( isolatedHTML: string, ): string { return indexHTML.replace( - /(]+id="boxel-isolated-start"[^>]*>\s*<\/script>)([\s\S]*?)(]+id="boxel-isolated-end"[^>]*>\s*<\/script>)/, + /(]+id="fastboot-body-start"[^>]*>\s*<\/script>)([\s\S]*?)(]+id="fastboot-body-end"[^>]*>\s*<\/script>)/, (_match, start, _content, end) => `${start}\n${isolatedHTML}\n${end}`, ); } + +export function injectRenderModeScript(indexHTML: string): string { + let script = ``; + let updated = indexHTML.replace( + /(]+data-boxel-head-end[^>]*>)/, + `$1\n${script}`, + ); + if (updated === indexHTML) { + return indexHTML.replace(/<\/head>/i, `${script}\n`); + } + return updated; +} diff --git a/packages/realm-server/prerender/page-pool.ts b/packages/realm-server/prerender/page-pool.ts index 6b07c71e96..0f596b243f 100644 --- a/packages/realm-server/prerender/page-pool.ts +++ b/packages/realm-server/prerender/page-pool.ts @@ -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', @@ -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(); diff --git a/packages/realm-server/prerender/remote-prerenderer.ts b/packages/realm-server/prerender/remote-prerenderer.ts index 5eb111081d..0437d75c1e 100644 --- a/packages/realm-server/prerender/remote-prerenderer.ts +++ b/packages/realm-server/prerender/remote-prerenderer.ts @@ -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 = diff --git a/packages/realm-server/prerender/utils.ts b/packages/realm-server/prerender/utils.ts index 33a260205b..8fe37973a9 100644 --- a/packages/realm-server/prerender/utils.ts +++ b/packages/realm-server/prerender/utils.ts @@ -68,6 +68,36 @@ export async function renderHTML( opts?: CaptureOptions, ): Promise { await transitionTo(page, 'render.html', format, String(ancestorLevel)); + + let markerInfo = await page.evaluate(() => { + let el = document.querySelector('[data-prerender]') as HTMLElement | null; + if (!el) return { hasContainer: false }; + let comments = Array.from(el.childNodes) + .filter((n) => n.nodeType === 8) + .map((n) => (n as Comment).nodeValue); + return { + hasContainer: true, + hasMarkerInContainer: el.innerHTML.includes('%+b:'), + commentSamples: comments.slice(0, 5), + }; + }); + log.info('container markers', markerInfo); + + let around = await page.evaluate(() => { + let el = document.querySelector('[data-prerender]'); + if (!el || !el.parentElement) return { hasParent: false }; + let parent = el.parentElement; + let prev = el.previousSibling; + let next = el.nextSibling; + return { + hasParent: true, + parentHasMarkers: parent.innerHTML.includes('%+b:'), + prevComment: prev?.nodeType === 8 ? prev.nodeValue : null, + nextComment: next?.nodeType === 8 ? next.nodeValue : null, + }; + }); + log.info('marker siblings', around); + let result = await captureResult( page, ['isolated', 'atom', 'head'].includes(format) ? 'innerHTML' : 'outerHTML', @@ -710,6 +740,10 @@ export async function captureResult( undefined, } as RenderCapture; } else { + // Serialize mode emits rehydrate markers as siblings of the container. + let useParentCapture = + capture !== 'textContent' && + (globalThis as any).__boxelRenderMode === 'serialize'; const firstChild = resolvedElement.children[0] as | (HTMLElement & { textContent: string; @@ -736,6 +770,40 @@ export async function captureResult( nonce: resolvedElement.dataset.prerenderNonce ?? undefined, } as RenderCapture; } + if (useParentCapture) { + let parent = resolvedElement.parentElement; + + // Collect elements and Glimmer serialisation comments + let pieces: string[] = []; + if (parent) { + let nodes = Array.from(parent.childNodes); + for (let node of nodes) { + if (node === resolvedElement) { + // Use the same capture mode as the non-serialize path: + // innerHTML → firstChild.innerHTML (strips wrapper div) + // outerHTML → firstChild.outerHTML (keeps wrapper div) + pieces.push((firstChild as any)[capture]); + continue; + } + if (node.nodeType === 8) { + let value = (node as Comment).nodeValue ?? ''; + if (value.includes('%+') || value.includes('%-')) { + pieces.push(``); + } + } + } + } + return { + status: finalStatus, + value: + pieces.length > 0 + ? pieces.join('') + : (firstChild as any)[capture], + alive, + id: resolvedElement.dataset.prerenderId ?? undefined, + nonce: resolvedElement.dataset.prerenderNonce ?? undefined, + } as RenderCapture; + } return { status: finalStatus, value: (firstChild as any)[capture]!, diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index b4d3fd3850..3da992e772 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -64,6 +64,7 @@ import { retrieveIsolatedHTML, injectHeadHTML, injectIsolatedHTML, + injectRenderModeScript, } from './lib/index-html-injection'; export class RealmServer { @@ -446,6 +447,7 @@ export class RealmServer { if (headFragments.length > 0) { responseHTML = injectHeadHTML(responseHTML, headFragments.join('\n')); + responseHTML = injectRenderModeScript(responseHTML); } if (isolatedHTML != null) { diff --git a/packages/realm-server/tests/prerendering-test.ts b/packages/realm-server/tests/prerendering-test.ts index 870b186bd6..b3aca63e59 100644 --- a/packages/realm-server/tests/prerendering-test.ts +++ b/packages/realm-server/tests/prerendering-test.ts @@ -1591,6 +1591,10 @@ module(basename(__filename), function () { cleanedHead.includes('name="twitter:card" content="summary"'), `failed to find twitter:card in head html:${cleanedHead}`, ); + assert.notOk( + cleanedHead.includes('boxel-card-container'), + `headHTML should not contain card container wrapper:${cleanedHead}`, + ); }); test('serialized', function (assert) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a93fd50dc6..2957fbe2ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9925,12 +9925,12 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==}