Skip to content

Commit f2c4d0a

Browse files
authored
Merge pull request #21075 from emberjs/nvp/check-void-mAlex-usecase
Add test that asserts behavior for when we try to renderComponent into the same element multiple times
2 parents c032963 + d35a8c9 commit f2c4d0a

2 files changed

Lines changed: 141 additions & 5 deletions

File tree

packages/@ember/-internals/glimmer/lib/renderer.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,16 @@ export interface RenderResult {
553553
destroy(): void;
554554
}
555555

556+
interface RenderCacheEntry {
557+
result: RenderResult;
558+
/**
559+
* The GlimmerRenderResult from the last render. Used to get positional
560+
* information (firstNode) when a re-render replaces the content, so
561+
* that the new content is placed at the same DOM position.
562+
*/
563+
glimmerResult: GlimmerRenderResult | undefined;
564+
}
565+
556566
function intoTarget(into: IntoTarget): Cursor {
557567
if ('element' in into) {
558568
return into;
@@ -655,7 +665,7 @@ export function renderComponent(
655665
* NOTE: destruction is async
656666
*/
657667
let existing = RENDER_CACHE.get(into);
658-
existing?.destroy();
668+
existing?.result.destroy();
659669
/**
660670
* We can only replace the inner HTML the first time.
661671
* Because destruction is async, it won't be safe to
@@ -665,26 +675,46 @@ export function renderComponent(
665675
into.innerHTML = '';
666676
}
667677

668-
let innerResult = renderer.render(component, { into, args }).result;
678+
/**
679+
* If there's an existing render result with valid bounds, use its
680+
* firstNode as the nextSibling so that new content is inserted at
681+
* the same DOM position. This ensures stable ordering when multiple
682+
* renderComponent calls target the same element and one is re-invoked
683+
* (e.g., due to tracked dependency changes).
684+
*
685+
* The old content's DOM nodes are still present (destruction is async),
686+
* so firstNode() is a valid position reference. The new content is placed
687+
* BEFORE the old content. When the old content is eventually destroyed
688+
* (async clear of bounds), the new content remains in the correct position.
689+
*/
690+
let renderTarget: IntoTarget = into;
691+
if (existing?.glimmerResult) {
692+
let parentElement =
693+
into instanceof Element ? (into as unknown as SimpleElement) : (into as Cursor).element;
694+
let firstNode = existing.glimmerResult.firstNode();
695+
renderTarget = { element: parentElement, nextSibling: firstNode };
696+
}
697+
698+
let innerResult = renderer.render(component, { into: renderTarget, args }).result;
669699

670700
if (innerResult) {
671701
associateDestroyableChild(owner, innerResult);
672702
}
673703

674-
let result = {
704+
let result: RenderResult = {
675705
destroy() {
676706
if (innerResult) {
677707
destroy(innerResult);
678708
}
679709
},
680710
};
681711

682-
RENDER_CACHE.set(into, result);
712+
RENDER_CACHE.set(into, { result, glimmerResult: innerResult });
683713

684714
return result;
685715
}
686716

687-
const RENDER_CACHE = new WeakMap<IntoTarget, RenderResult>();
717+
const RENDER_CACHE = new WeakMap<IntoTarget, RenderCacheEntry>();
688718
const RENDERER_CACHE = new WeakMap<object, BaseRenderer>();
689719

690720
export class BaseRenderer {

packages/@ember/-internals/glimmer/tests/integration/components/render-component-test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,112 @@ moduleFor(
638638
assertHTML('');
639639
}
640640

641+
'@test multiple calls to render in to the same element appear as siblings'() {
642+
let aHelper = (str: string) => str.toUpperCase();
643+
let Child = defComponent(`Hi: {{aHelper "there"}}`, { scope: { aHelper } });
644+
let get = (id: string) => this.element.querySelector(id);
645+
function render(Comp: GlimmerishComponent, id: string, owner: Owner) {
646+
renderComponent(Comp, {
647+
into: get(`#${id}`)!,
648+
owner,
649+
});
650+
}
651+
let A = defComponent('a:<Child />', { scope: { Child } });
652+
let Root = defComponent(
653+
[
654+
`<div id="a"></div><br>`,
655+
// both of these go in the div
656+
`{{render A 'a' owner}}`,
657+
`{{render A 'a'}}`,
658+
].join('\n'),
659+
{ scope: { render, A, owner: this.owner } }
660+
);
661+
662+
this.renderComponent(Root, {
663+
expect: [`<div id="a">a:Hi: THEREa:Hi: THERE</div><br>`, '', ''].join('\n'),
664+
});
665+
run(() => destroy(this));
666+
667+
assertHTML('');
668+
}
669+
670+
/**
671+
* NOTE: subsequent renders to the same element are prepended to the element's children
672+
*/
673+
'@test multiple calls to render in to the same element appear as siblings and can be updated'() {
674+
let aHelper = (str: string) => str.toUpperCase();
675+
let dataA = trackedObject({ count: 1 });
676+
let dataB = trackedObject({ count: -1 });
677+
let Child = defComponent(`Hi: {{aHelper "there"}}`, { scope: { aHelper } });
678+
679+
let get = (id: string) => this.element.querySelector(id);
680+
function render(Comp: GlimmerishComponent, id: string, owner: Owner) {
681+
renderComponent(Comp, {
682+
into: get(`#${id}`)!,
683+
owner,
684+
});
685+
}
686+
let A = defComponent('\n<output>a:<Child />:{{data.count}}</output>\n', {
687+
scope: { Child, data: dataA },
688+
});
689+
let B = defComponent('\n<output>b:<Child />:{{data.count}}</output>', {
690+
scope: { Child, data: dataB },
691+
});
692+
693+
let Root = defComponent(
694+
[
695+
`<div id="a"></div><br>`,
696+
// both of these go in the div
697+
`{{render A 'a' owner}}`,
698+
`{{render B 'a'}}`,
699+
].join('\n'),
700+
{ scope: { render, A, B, owner: this.owner } }
701+
);
702+
703+
this.renderComponent(Root, {
704+
expect: [
705+
`<div id="a">`,
706+
`<output>b:Hi: THERE:-1</output>`,
707+
`<output>a:Hi: THERE:1</output>`,
708+
`</div><br>`,
709+
'',
710+
'',
711+
].join('\n'),
712+
});
713+
714+
this.assertChange({
715+
change: () => dataA.count++,
716+
expect: [
717+
`<div id="a">`,
718+
`<output>b:Hi: THERE:-1</output>`,
719+
`<output>a:Hi: THERE:2</output>`,
720+
`</div><br>`,
721+
'',
722+
'',
723+
].join('\n'),
724+
});
725+
726+
/**
727+
* ERROR in conflict with the NOTE on this test.
728+
* the elementns flip locations... which is kinda bonkers
729+
*/
730+
this.assertChange({
731+
change: () => dataB.count--,
732+
expect: [
733+
`<div id="a">`,
734+
`<output>b:Hi: THERE:-2</output>`,
735+
`<output>a:Hi: THERE:2</output>`,
736+
`</div><br>`,
737+
'',
738+
'',
739+
].join('\n'),
740+
});
741+
742+
run(() => destroy(this));
743+
744+
assertHTML('');
745+
}
746+
641747
async '@test async rendering multiple times to adjacent elements'() {
642748
let Child = defComponent(`Hi`, { scope: {} });
643749
let get = (id: string) => this.element.querySelector(id);

0 commit comments

Comments
 (0)