Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './suites/entry-point';
export * from './suites/has-block';
export * from './suites/has-block-params';
export * from './suites/in-element';
export * from './suites/in-element-document-fragment';
export * from './suites/initial-render';
export * from './suites/scope';
export * from './suites/shadowing';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { RenderTest } from '../render-test';
import { test } from '../test-decorator';

export class InElementDocumentFragmentSuite extends RenderTest {
static suiteName = '#in-element (DocumentFragment)';

@test
'Renders curlies into a detached DocumentFragment'() {
const fragment = document.createDocumentFragment();

this.render('{{#in-element this.fragment}}[{{this.foo}}]{{/in-element}}', {
fragment,
foo: 'Hello Fragment!',
});

this.assert.strictEqual(
fragment.textContent,
'[Hello Fragment!]',
'content rendered in document fragment'
);
this.assertHTML('<!---->');
this.assertStableRerender();

this.rerender({ foo: 'Updated!' });
this.assert.strictEqual(
fragment.textContent,
'[Updated!]',
'content updated in document fragment'
);
this.assertHTML('<!---->');

this.rerender({ foo: 'Hello Fragment!' });
this.assert.strictEqual(
fragment.textContent,
'[Hello Fragment!]',
'content reverted in document fragment'
);
this.assertHTML('<!---->');
}

@test
'Renders curlies into a template.content fragment'() {
const templateEl = document.createElement('template');
const fragment = templateEl.content;

this.render('{{#in-element this.fragment}}[{{this.foo}}]{{/in-element}}', {
fragment,
foo: 'Hello Template Content!',
});

this.assert.strictEqual(
fragment.textContent,
'[Hello Template Content!]',
'content rendered in template.content fragment'
);
this.assertHTML('<!---->');
this.assertStableRerender();

this.rerender({ foo: 'Updated!' });
this.assert.strictEqual(
fragment.textContent,
'[Updated!]',
'content updated in template.content fragment'
);
this.assertHTML('<!---->');

this.rerender({ foo: 'Hello Template Content!' });
this.assert.strictEqual(
fragment.textContent,
'[Hello Template Content!]',
'content reverted in template.content fragment'
);
this.assertHTML('<!---->');
}

@test
'Renders elements into a fragment that is later attached to the DOM'() {
const fragment = document.createDocumentFragment();
const container = document.createElement('div');

this.render('{{#in-element this.fragment}}<p id="frag-p">{{this.message}}</p>{{/in-element}}', {
fragment,
message: 'in fragment',
});

this.assert.strictEqual(
fragment.querySelector('#frag-p')?.textContent,
'in fragment',
'content rendered in detached fragment'
);
this.assertHTML('<!---->');

// Attach fragment's children to the DOM
container.appendChild(fragment);
Comment thread
NullVoxPopuli marked this conversation as resolved.
this.assert.strictEqual(
container.querySelector('#frag-p')?.textContent,
'in fragment',
'content is in the DOM after fragment is appended'
);
// Fragment itself is now empty (children moved to container)
this.assert.strictEqual(fragment.childNodes.length, 0, 'fragment is empty after append');
}

@test
'Multiple in-element calls to the same DocumentFragment'() {
const fragment = document.createDocumentFragment();

this.render(
'{{#in-element this.fragment}}[{{this.foo}}]{{/in-element}}' +
'{{#in-element this.fragment insertBefore=null}}[{{this.bar}}]{{/in-element}}',
{
fragment,
foo: 'first',
bar: 'second',
}
);

this.assert.ok(fragment.textContent?.includes('[first]'), 'first block present in fragment');
this.assert.ok(fragment.textContent?.includes('[second]'), 'second block present in fragment');
this.assertHTML('<!----><!---->');
this.assertStableRerender();

this.rerender({ foo: 'updated-first', bar: 'updated-second' });
this.assert.ok(
fragment.textContent?.includes('[updated-first]'),
'first block updated in fragment'
);
this.assert.ok(
fragment.textContent?.includes('[updated-second]'),
'second block updated in fragment'
);
this.assertHTML('<!----><!---->');
}

@test
'Rerenders work after DocumentFragment is appended to the DOM'(assert: typeof QUnit.assert) {
const fragment = document.createDocumentFragment();
const container = document.createElement('div');
const step = (text: string) => {
assert.step(text);
return text;
};

this.render(
'{{#in-element this.fragment}}' +
'<p id="msg">{{this.step this.message}}</p>' +
'{{#if this.show}}' +
'<span id="extra">extra {{this.step "extra rendered"}}</span>' +
'{{/if}}' +
'{{/in-element}}',
{
fragment,
message: 'initial',
show: false,
step,
}
);

assert.verifySteps(['initial'], 'initial render fires step from inside fragment');

// Move the fragment's children into the container. After this the fragment is
// empty, but the rendered nodes (including Glimmer's bounds markers) are live
// children of `container`.
container.appendChild(fragment);
assert.strictEqual(fragment.childNodes.length, 0, 'fragment is empty after append');
assert.ok(container.querySelector('#msg'), 'paragraph is present in container after append');

// Rerenders should continue to work after the fragment is attached — Glimmer
// resolves the live parent from the bounds markers' actual parentNode.
this.rerender({ message: 'updated' });
assert.verifySteps(['updated'], 'text update fires step after fragment was attached to DOM');
assert.strictEqual(
container.querySelector('#msg')?.textContent,
'updated',
'paragraph text is updated in container'
);

// New conditional element should appear in the container.
this.rerender({ show: true });
assert.verifySteps(
['extra rendered'],
'conditional element step fires in container after fragment was attached to DOM'
);
assert.ok(
container.querySelector('#extra'),
'conditional span appears in container after fragment was attached to DOM'
);
}

@test
'Multiple in-element calls to the same DocumentFragment with insertBefore=null'() {
const fragment = document.createDocumentFragment();

this.render(
'{{#in-element this.fragment insertBefore=null}}<p id="a">{{this.foo}}</p>{{/in-element}}' +
'{{#in-element this.fragment insertBefore=null}}<p id="b">{{this.bar}}</p>{{/in-element}}',
{
fragment,
foo: 'first',
bar: 'second',
}
);

// Use childNodes to traverse the fragment's direct children since glimmer also
// inserts comment marker nodes alongside the rendered elements.
const nodes = Array.from(fragment.childNodes);
const pA = nodes.find((n) => (n as Element).id === 'a') as HTMLElement | undefined;
const pB = nodes.find((n) => (n as Element).id === 'b') as HTMLElement | undefined;

this.assert.strictEqual(pA?.textContent, 'first', 'first block appended to fragment');
this.assert.strictEqual(pB?.textContent, 'second', 'second block appended to fragment');
this.assertHTML('<!----><!---->');
this.assertStableRerender();

this.rerender({ foo: 'updated-first', bar: 'updated-second' });
this.assert.strictEqual(pA?.textContent, 'updated-first', 'first block updated in fragment');
this.assert.strictEqual(pB?.textContent, 'updated-second', 'second block updated in fragment');
this.assertHTML('<!----><!---->');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
GlimmerishComponents,
HasBlockParamsHelperSuite,
HasBlockSuite,
InElementDocumentFragmentSuite,
InElementSuite,
jitComponentSuite,
jitSuite,
Expand All @@ -18,6 +19,7 @@ import {
jitComponentSuite(DebuggerSuite);
jitSuite(EachSuite);
jitSuite(InElementSuite);
jitSuite(InElementDocumentFragmentSuite);

jitComponentSuite(GlimmerishComponents);
jitComponentSuite(TemplateOnlyComponents);
Expand Down
7 changes: 6 additions & 1 deletion packages/@glimmer/runtime/lib/bounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,15 @@ export function move(bounds: Bounds, reference: Nullable<SimpleNode>): Nullable<
}

export function clear(bounds: Bounds): Nullable<SimpleNode> {
let parent = bounds.parentElement();
let first = bounds.firstNode();
let last = bounds.lastNode();

// Use the node's actual current parent rather than the stored parentElement.
// When bounds were rendered into a DocumentFragment that was subsequently
// appended to a real DOM container, the nodes' parentNode is the container
// while parentElement() still returns the (now-empty) fragment.
let parent = (first.parentNode as Nullable<SimpleElement>) ?? bounds.parentElement();

let current: SimpleNode = first;

while (true) {
Expand Down
8 changes: 7 additions & 1 deletion packages/@glimmer/runtime/lib/compiled/opcodes/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ModifierInstance,
Nullable,
Owner,
SimpleElement,
UpdatingOpcode,
UpdatingVM,
} from '@glimmer/interfaces';
Expand All @@ -29,10 +30,12 @@ import {
} from '@glimmer/constants';
import {
check,
CheckDocumentFragment,
CheckElement,
CheckMaybe,
CheckNode,
CheckNullable,
CheckOr,
CheckString,
} from '@glimmer/debug';
import { debugToString, expect } from '@glimmer/debug-util';
Expand Down Expand Up @@ -74,7 +77,10 @@ APPEND_OPCODES.add(VM_PUSH_REMOTE_ELEMENT_OP, (vm) => {
let insertBeforeRef = check(vm.stack.pop(), CheckReference);
let guidRef = check(vm.stack.pop(), CheckReference);

let element = check(valueForRef(elementRef), CheckElement);
let element = check(
valueForRef(elementRef),
CheckOr(CheckElement, CheckDocumentFragment)
) as SimpleElement;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not cast. Push the type change to the implementation so that we can really show it's always safe to pass document fragments.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type changed pushed here: 0e0c6d5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing the type changes makes me think we're missing something here, and the preexisting uses of SimpleElement that this PR is forced to expand to SimpleElement | SimpleDocumentFragment should possibly go the other direction to SimpleNode. For example, this:

export interface Bounds {
  // ...
  parentElement(): SimpleElement;
  // ...
}

Should probably really be:

export interface Bounds {
  // ...
  parentNode(): SimpleNode;
  // ...
}

And then you would get support for DocumentFragment (and critically also Document!).

HTML Node's parentElement is defined to always be Element or null. It's never a document fragment or document. Whereas parentNode is allowed to be a DocumentFragment, etc.

I think the existing types were unnecessarily specific when they used SimpleElement instead of SimpleNode in most o the places this PR had to touch. Most of them a clearly not relying on any Element-specific API, since they happily tolerate DocumentFragment instead with very few changes.

let insertBefore = check(valueForRef(insertBeforeRef), CheckMaybe(CheckNullable(CheckNode)));
let guid = valueForRef(guidRef) as string;

Expand Down
14 changes: 12 additions & 2 deletions packages/@glimmer/runtime/lib/vm/element-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ export class NewTreeBuilder implements TreeBuilder {
}

static resume(env: Environment, block: ResettableBlock): NewTreeBuilder {
let parentNode = block.parentElement();
// Capture the live parent before resetting, because the bounds may have been
// rendered into a DocumentFragment that was subsequently appended to a real
// DOM container. In that case firstNode().parentNode is the container while
// parentElement() still returns the original (now-empty) fragment.
let parentNode =
(block.firstNode().parentNode as SimpleElement | null) ?? block.parentElement();
let nextSibling = block.reset(env);

let stack = new this(env, parentNode, nextSibling).initialize();
Expand Down Expand Up @@ -500,7 +505,12 @@ export class RemoteBlock extends AppendingBlockImpl {
// and avoid clearing the node if it was. In most cases this shouldn't happen,
// so this might hide bugs where the code clears nested nodes unnecessarily,
// so we should eventually try to do the correct fix.
if (this.parentElement() === this.firstNode().parentNode) {
//
// Note: we check firstNode().parentNode !== null (node still has a parent)
// rather than === parentElement() (node is in the original parent), so that
// {{#in-element}} into a DocumentFragment still clears correctly after the
// fragment's children are moved to a real DOM container via appendChild().
if (this.firstNode().parentNode !== null) {
clear(this);
}
});
Expand Down
Loading