Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
cb1b6ae
Change injection root
backspace Feb 10, 2026
849ec32
Add partial rehydration
backspace Feb 11, 2026
d3176ba
Add more rehydration work
backspace Feb 11, 2026
cd07fc0
Merge branch 'main' into host/rehydration-cs-9977
backspace Feb 12, 2026
43e9dfe
Add lint autofixes
backspace Feb 12, 2026
cf3774c
auto: fix lint formatting (ignoring unfixable errors) (#3985)
backspace Feb 12, 2026
c3a237c
Add deliberate lint error
backspace Feb 12, 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
53 changes: 51 additions & 2 deletions packages/host/app/components/prerendered-card-search.gts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,54 @@ export default class PrerenderedCardSearch extends Component<PrerenderedCardComp
normalizeRealms(this.args.realms),
);

constructor(owner: unknown, args: PrerenderedCardComponentSignature['Args']) {
super(owner, args);
this.initFromShoebox();
}

// During rehydration, pre-populate search results from shoebox data so the
// first render produces the response block (matching the prerendered DOM)
// instead of the loading block which would cause a rehydration mismatch.
private initFromShoebox() {
let shoeboxData = (globalThis as any).__boxelShoeboxData;
let renderMode = (globalThis as any).__boxelRenderMode;
if (
!shoeboxData ||
(renderMode !== 'rehydrate' && renderMode !== 'serialize')
) {
return;
}

let realms = normalizeRealms(this.args.realms);

for (let key of Object.keys(shoeboxData)) {
if (!key.startsWith('__search:')) continue;
let json = shoeboxData[key];
if (!isPrerenderedCardCollectionDocument(json)) continue;

let instances = json.data.filter(Boolean).map((r: any) => {
let realmUrl = resolveCardRealmUrl(r.id, realms);
return new PrerenderedCard(
{
url: r.id,
realmUrl,
html: r.attributes?.html,
isError: !!r.attributes?.isError,
},
undefined, // no modifier during rehydration — DOM already correct
);
});

this._lastSearchResults = {
instances,
meta: json.meta ?? { page: { total: 0 } },
};
this._lastSearchQuery = this.args.query ?? null;
this._lastRealms = realms;
break;
}
}

private get cardComponentModifier() {
if (isDestroying(this) || isDestroyed(this)) {
return undefined;
Expand Down Expand Up @@ -411,8 +459,9 @@ export default class PrerenderedCardSearch extends Component<PrerenderedCardComp
});

private get searchResults() {
if (this.runSearch.value) {
return this.runSearch.value;
let runSearchValue = this.runSearch.value;
if (runSearchValue) {
return runSearchValue;
} else if (this._lastSearchResults) {
return {
instances: this._lastSearchResults.instances,
Expand Down
6 changes: 1 addition & 5 deletions packages/host/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,11 @@
<meta data-boxel-head-start />
<title>Boxel</title>
<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>
<div id="boxel-root"></div>

<!-- in case embercli's hooks insn't run,
we embed the following div manually -->
<div id="ember-basic-dropdown-wormhole"></div>
{{content-for "body"}}

Expand Down
222 changes: 222 additions & 0 deletions packages/host/app/initializers/experimental-rehydrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
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);
}

// Wraps the standard rehydrationBuilder with a fix for Glimmer's handling
// of empty-text markers (<!--% %-->). When __appendText("") encounters a
// % % comment, the stock rehydrate builder removes it and recurses via
// this.__appendText(""). The recursive call then hits the next candidate
// (typically a close-block marker <!--%-b:N%-->) and triggers clearMismatch,
// causing the entire subtree to be re-rendered.
//
// The fix intercepts this case: when __appendText("") finds a % % comment,
// it replaces the comment with an empty text node in-place (preserving DOM
// position for bounds tracking) and advances the candidate pointer past it.
function fixedRehydrationBuilder(env: any, cursor: any) {
let builder = rehydrationBuilder(env, cursor);
let origAppendText = builder.__appendText.bind(builder);
let origClearMismatch = builder.clearMismatch.bind(builder);

builder.clearMismatch = function (candidate: any) {
console.warn(
'[rehydration] MISMATCH at',
candidate?.nodeName,
candidate?.nodeType === 8
? candidate.nodeValue
: candidate?.nodeType === 3
? 'text:' + JSON.stringify(candidate.nodeValue?.slice(0, 80))
: candidate?.outerHTML?.slice(0, 120),
'parent:',
candidate?.parentNode?.tagName,
candidate?.parentNode?.id,
);
return origClearMismatch(candidate);
};

builder.__appendText = function (string: string) {
let candidate = this.currentCursor?.candidate;
if (
string === '' &&
candidate &&
candidate.nodeType === 8 &&
candidate.nodeValue === '% %'
) {
let textNode = document.createTextNode('');
let parent = candidate.parentNode!;
let next = candidate.nextSibling;
parent.replaceChild(textNode, candidate);
this.currentCursor.candidate = next;
return textNode;
}
// When __appendText is called with whitespace-only content but the
// candidate is an element node (not a text node or comment), the
// serialized HTML has interstitial whitespace between block markers
// that was consumed during marker processing. Instead of triggering
// clearMismatch (which destroys the remaining subtree), insert a
// whitespace text node before the element — this preserves DOM
// identity for the element and its descendants.
if (
string.trim() === '' &&
candidate &&
candidate.nodeType === 1 // Element node
) {
let textNode = document.createTextNode(string);
candidate.parentNode!.insertBefore(textNode, candidate);
// Don't advance candidate — the element is still next
return textNode;
}
return origAppendText(string);
};

return builder;
}

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() {
// Allow ?serialize query param to force serialize mode for debugging
if (
typeof document !== 'undefined' &&
window.location?.search?.includes('serialize')
) {
console.log(
'[ember-host] Boxel render mode override: serialize (via query param)',
);
return serializeBuilder.bind(null);
} else if (
typeof document !== 'undefined' &&
// @ts-expect-error hmm
globalThis.__boxelRenderMode === 'rehydrate'
) {
console.log('[ember-host] Boxel render mode override: rehydrate!');
return fixedRehydrationBuilder.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,
};
68 changes: 68 additions & 0 deletions packages/host/app/instance-initializers/populate-shoebox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type ApplicationInstance from '@ember/application/instance';

// This instance initializer pre-loads card instances from shoebox data into the
// store before routing starts. This ensures that store.peek() returns card
// instances during the first (rehydration) render so the template output
// matches the prerendered DOM and the rehydration builder can adopt it without
// triggering clearMismatch.
//
// Timing: instance initializers run BEFORE router.startRouting() inside
// ApplicationInstance._bootSync(). We monkey-patch startRouting to delay it
// until the preload promise settles, then call the original.

export function initialize(appInstance: ApplicationInstance): void {
let shoeboxData = (globalThis as any).__boxelShoeboxData;
if (!shoeboxData || (globalThis as any).__boxelRenderMode !== 'rehydrate') {
return;
}

let store = appInstance.lookup('service:store') as any;
// Only preload card URLs — skip internal keys like __search:*
let shoeboxUrls = Object.keys(shoeboxData).filter(
(key) => !key.startsWith('__'),
);

if (shoeboxUrls.length === 0) {
return;
}

// Routes use URLs like /minicatalog/ but the shoebox keys are canonical
// (/minicatalog/index). We must preload using the route-format URL so the
// store registers the card under the key that peek() will be called with.
// The fetch interceptor handles the /index fallback.
let routeUrls = shoeboxUrls.map((url) =>
url.endsWith('/index') ? url.slice(0, -'index'.length) : url,
);
let allUrls = [...new Set([...routeUrls, ...shoeboxUrls])];

let preloadPromise = (async () => {
await store.ensureSetupComplete();
await Promise.all(
allUrls.map((url: string) =>
store.get(url).catch((e: any) => {
console.warn('[shoebox] Failed to preload card:', url, e);
}),
),
);
})();

// Delay routing until the store is populated. The router singleton is
// already registered; we just patch its startRouting method.
let router = appInstance.lookup('router:main') as any;
let originalStartRouting = router.startRouting.bind(router);
router.startRouting = function () {
preloadPromise
.catch((e: any) => {
console.error('[shoebox] Pre-load failed, starting routing anyway:', e);
})
.then(() => {
// Restore original method so future calls are not affected
router.startRouting = originalStartRouting;
originalStartRouting();
});
};
}

export default {
initialize,
};
5 changes: 5 additions & 0 deletions packages/host/app/lib/html-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ export function htmlComponent(
html: string,
extraAttributes: Record<string, string> = {},
): HTMLComponent {
// Strip Glimmer serialization block markers (<!--%+b:N%-->, <!--%-b:N%-->,
// <!--% %-->) that the SerializeBuilder injects during prerendering.
// These comments are consumed by the RehydrateBuilder on the client but
// must not be present when parsing isolated card HTML for htmlComponent.
html = html.replace(/<!--%[^%]*%-->/g, '');
let testContainer = document.createElement('div');
testContainer.innerHTML = html;
if (
Expand Down
Loading
Loading