Skip to content

Commit f920e80

Browse files
committed
Adding a couple minor changes for 2.x interface.
Changes: * Stricter handling of “textarea” binding. Previously we were overly lenient in how the binding worked which caused automagical behavior. For example, newlines were quietly ignored, which could lead to issues if you really _wanted_ newlines in a default value in a text area control and were actually trying to interpolate between lines. Now, it just throws for that case. * Spec-compliant attribute traversal. Previously, we relied on the browser convention that attributes in a NamedNodeMap would be iterated over in a particular manner. The spec strictly indicates that this is not to be relied on — so we can oblige. * The deprecated `plaintext` html tag is no longer considered. Use at your own peril… * General improvements to inline documentation and naming of things.
1 parent db0788b commit f920e80

File tree

3 files changed

+197
-23
lines changed

3 files changed

+197
-23
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
- The `ifDefined` updater now deletes the attribute on `null` in addition to
2121
`undefined`. This makes it behave identically to `nullish`. However, both
2222
updaters are deprecated and the `??attr` binding should be used instead.
23+
- Interpolation of `textarea` is stricter. This used to be handled with some
24+
leniency — `<textarea>\n ${value} \n</textarea>`. Now, you have to fit the
25+
interpolation exactly — `<textarea></textarea>`.
2326

2427
### Deprecated
2528

2629
- The `ifDefined` and `nullish` updaters are deprecated, update templates to use
2730
syntax like `??foo="${bar}"`.
2831
- The `repeat` updater is deprecated, use `map` instead.
2932
- The `unsafeHTML` and `unsafeSVG` updaters are deprecated, use `unsafe`.
33+
- The `plaintext` tag is no longer handled. This is a deprecated html tag which
34+
required special handling… but it’s unlikely that anyone is using that.
3035

3136
### Fixed
3237

test/test-template-engine.js

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,75 @@ describe('html rendering', () => {
6565
container.remove();
6666
});
6767

68+
// Unlike a NodeList, a NamedNodeMap (i.e., “.attributes”) is not technically
69+
// ordered in any way. This test just confirms that the template engine logic
70+
// doesn’t get confused in any way post-parse.
71+
it('renders elements with many attributes in a weird order', () => {
72+
const getTemplate = ({
73+
property1,
74+
z99,
75+
defined,
76+
dataFoo,
77+
property2,
78+
title,
79+
dataBar,
80+
className,
81+
property3,
82+
boolean,
83+
ariaLabel,
84+
content,
85+
}) => {
86+
return html`
87+
<div
88+
id="target"
89+
.property1="${property1}"
90+
z-99="${z99}"
91+
??defined="${defined}"
92+
data-foo="${dataFoo}"
93+
.property2="${property2}"
94+
title="${title}"
95+
static="just hanging"
96+
data-bar="${dataBar}"
97+
class="${className}"
98+
.property3="${property3}"
99+
?boolean="${boolean}"
100+
aria-label="${ariaLabel}">
101+
${content}
102+
</div>
103+
`;
104+
};
105+
const container = document.createElement('div');
106+
document.body.append(container);
107+
render(container, getTemplate({
108+
property1: null,
109+
z99: true,
110+
defined: false,
111+
dataFoo: 10,
112+
property2: -1,
113+
title: 'a title',
114+
dataBar: 'data attribute',
115+
className: 'a b c',
116+
property3: new URL('https://github.com/Netflix/x-element'),
117+
boolean: 'yes',
118+
ariaLabel: 'this is what it does',
119+
content: 'influencing',
120+
}));
121+
const target = container.querySelector('#target');
122+
assert(target.property1 === null);
123+
assert(target.getAttribute('z-99') === 'true');
124+
assert(target.getAttribute('defined') === 'false');
125+
assert(target.getAttribute('data-foo') === '10');
126+
assert(target.property2 === -1);
127+
assert(target.getAttribute('title') === 'a title');
128+
assert(target.getAttribute('data-bar') === 'data attribute');
129+
assert(target.getAttribute('class') === 'a b c');
130+
assert(target.property3.href === 'https://github.com/Netflix/x-element');
131+
assert(target.getAttribute('boolean') === '');
132+
assert(target.getAttribute('aria-label') === 'this is what it does');
133+
assert(target.textContent.trim() === 'influencing');
134+
container.remove();
135+
});
136+
68137
it('renders multiple, interpolated content', () => {
69138
const getTemplate = ({ one, two }) => {
70139
return html`
@@ -1058,6 +1127,38 @@ describe('rendering errors', () => {
10581127
container.remove();
10591128
});
10601129

1130+
it('throws when attempting non-trivial interpolation of a textarea tag', () => {
1131+
const getTemplate = ({ content }) => {
1132+
return html`<textarea id="target">please ${content} no</textarea>`;
1133+
};
1134+
const container = document.createElement('div');
1135+
document.body.append(container);
1136+
let error;
1137+
try {
1138+
render(container, getTemplate({ content: 'foo' }));
1139+
} catch (e) {
1140+
error = e;
1141+
}
1142+
assert(error?.message === `Only basic interpolation of "textarea" tags is allowed.`, error.message);
1143+
container.remove();
1144+
});
1145+
1146+
it('throws when attempting non-trivial interpolation of a title tag', () => {
1147+
const getTemplate = ({ content }) => {
1148+
return html`<title id="target">please ${content} no</title>`;
1149+
};
1150+
const container = document.createElement('div');
1151+
document.body.append(container);
1152+
let error;
1153+
try {
1154+
render(container, getTemplate({ defaultValue: 'foo' }));
1155+
} catch (e) {
1156+
error = e;
1157+
}
1158+
assert(error?.message === `Only basic interpolation of "title" tags is allowed.`, error.message);
1159+
container.remove();
1160+
});
1161+
10611162
it('throws for unquoted attributes', () => {
10621163
const templateResultReference = html`<div id="target" not-ok=${'foo'}>Gotta double-quote those.</div>`;
10631164
const container = document.createElement('div');

x-element.js

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,7 @@ class TemplateEngine {
10181018
static #DEFINED_PREFIX = 'x-element-defined';
10191019
static #PROPERTY_PREFIX = 'x-element-property';
10201020
static #CONTENT_PREFIX = 'x-element-content';
1021+
static #ATTRIBUTE_PADDING = 6;
10211022

10221023
// Patterns to find special edges in original html strings.
10231024
static #OPEN_REGEX = /<[a-z][a-z0-9-]*(?=\s)/g;
@@ -1475,6 +1476,9 @@ class TemplateEngine {
14751476
TemplateEngine.#mapInner(node, startNode, identify, callback, value, 'repeat');
14761477
}
14771478

1479+
// Walk through each string from our tagged template function “strings” array
1480+
// in a stateful way so that we know what kind of bindings are implied at
1481+
// each interpolated value.
14781482
static #exhaustString(string, state) {
14791483
if (!state.inside) {
14801484
// We're outside the opening tag.
@@ -1502,6 +1506,29 @@ class TemplateEngine {
15021506
}
15031507
}
15041508

1509+
// Flesh out an html string from our tagged template function “strings” array
1510+
// and add special markers that we can detect later, after instantiation.
1511+
//
1512+
// E.g., the user might have passed this interpolation:
1513+
//
1514+
// <div
1515+
// id="foo-bar-baz"
1516+
// foo="${foo}"
1517+
// bar="${bar}"
1518+
// .baz="${baz}">
1519+
// ${content}
1520+
// </div>
1521+
//
1522+
// … and we would instrument it as follows:
1523+
//
1524+
// <div
1525+
// id="foo-bar-baz"
1526+
// x-element-attribute-000001="foo"
1527+
// x-element-attribute-000002="bar"
1528+
// x-element-property-000003="baz">
1529+
// <!--x-element-content-->
1530+
// </div>
1531+
//
15051532
static #createHtml(type, strings) {
15061533
const htmlStrings = [];
15071534
const state = { inside: false, index: 0 };
@@ -1527,13 +1554,15 @@ class TemplateEngine {
15271554
case '??': prefix = TemplateEngine.#DEFINED_PREFIX; syntax = 4; break;
15281555
case '?': prefix = TemplateEngine.#BOOLEAN_PREFIX; syntax = 3; break;
15291556
}
1530-
string = string.slice(0, -syntax - attribute.length) + `${prefix}-${iii}="${attribute}`;
1557+
const index = String(iii).padStart(TemplateEngine.#ATTRIBUTE_PADDING, '0');
1558+
string = string.slice(0, -syntax - attribute.length) + `${prefix}-${index}="${attribute}`;
15311559
} else {
15321560
// We found a match like this: html`<div .title="${value}"></div>`.
15331561
// The syntax takes up 3 characters: `.${property}="`.
15341562
const syntax = 3;
15351563
const prefix = TemplateEngine.#PROPERTY_PREFIX;
1536-
string = string.slice(0, -syntax - property.length) + `${prefix}-${iii}="${property}`;
1564+
const index = String(iii).padStart(TemplateEngine.#ATTRIBUTE_PADDING, '0');
1565+
string = string.slice(0, -syntax - property.length) + `${prefix}-${index}="${property}`;
15371566
}
15381567
state.index = 1; // Accounts for an expected quote character next.
15391568
} else {
@@ -1563,8 +1592,35 @@ class TemplateEngine {
15631592
return template.content;
15641593
}
15651594

1566-
static #findLookups(node, nodeType = Node.DOCUMENT_FRAGMENT_NODE, path = []) {
1567-
const lookups = [];
1595+
// Walk through our fragment that we added special markers to and collect
1596+
// paths to each future target. We use “paths” because each future instance
1597+
// will clone this fragment and so paths are all we can really cache. And,
1598+
// while we go through, clean up our bespoke markers.
1599+
// Note that we are always walking the interpolated strings and the resulting,
1600+
// instantiated DOM _in the same depth-first manner_. This means that the
1601+
// ordering is fairly reliable. The only special handling we need to do is to
1602+
// ensure that we don’t rely on the ordering of NamedNodeMap objects since
1603+
// the spec doesn’t guarantee anything there (though in practice, it would
1604+
// probably work…).
1605+
//
1606+
// For example, we walk this structure:
1607+
//
1608+
// <div
1609+
// id="foo-bar-baz"
1610+
// x-element-attribute-000001="foo"
1611+
// x-element-attribute-000002="bar"
1612+
// x-element-property-000003="baz">
1613+
// <!--x-element-content-->
1614+
// </div>
1615+
//
1616+
// And end up with this (which is ready to be injected into a container):
1617+
//
1618+
// <div id="foo-bar-baz">
1619+
// <!---->
1620+
// <!---->
1621+
// </div>
1622+
//
1623+
static #findLookups(node, nodeType = Node.DOCUMENT_FRAGMENT_NODE, lookups = [], path = []) {
15681624
// @ts-ignore — TypeScript doesn’t seem to understand the nodeType param.
15691625
if (nodeType === Node.ELEMENT_NODE) {
15701626
// Copy the live NamedNodeMap since we need to mutate it during iteration.
@@ -1581,8 +1637,9 @@ class TemplateEngine {
15811637
? 'defined'
15821638
: null;
15831639
if (type) {
1640+
const index = Number(name.slice(-TemplateEngine.#ATTRIBUTE_PADDING));
15841641
const value = attribute.value;
1585-
lookups.push({ path, type, name: value });
1642+
lookups[index] = { path, type, name: value };
15861643
node.removeAttribute(name);
15871644
}
15881645
}
@@ -1593,13 +1650,14 @@ class TemplateEngine {
15931650
node.textContent.includes(TemplateEngine.#CONTENT_PREFIX)
15941651
) {
15951652
throw new Error(`Interpolation of "${localName}" tags is not allowed.`);
1596-
} else if (
1597-
localName === 'plaintext' ||
1598-
localName === 'textarea' ||
1599-
localName === 'title'
1600-
) {
1653+
} else if (localName === 'textarea' || localName === 'title') {
16011654
if (node.textContent.includes(TemplateEngine.#CONTENT_PREFIX)) {
1602-
lookups.push({ path, type: 'text' });
1655+
if (node.textContent === `<!--${TemplateEngine.#CONTENT_PREFIX}-->`) {
1656+
node.textContent = '';
1657+
lookups.push({ path, type: 'text' });
1658+
} else {
1659+
throw new Error(`Only basic interpolation of "${localName}" tags is allowed.`);
1660+
}
16031661
}
16041662
}
16051663
} else if (
@@ -1621,13 +1679,18 @@ class TemplateEngine {
16211679
for (const childNode of node.childNodes) {
16221680
const childNodeType = childNode.nodeType;
16231681
if (childNodeType === Node.ELEMENT_NODE || Node.COMMENT_NODE) {
1624-
lookups.push(...TemplateEngine.#findLookups(childNode, childNodeType, [...path, iii++]));
1682+
TemplateEngine.#findLookups(childNode, childNodeType, lookups, [...path, iii++]);
16251683
}
16261684
}
16271685
}
1628-
return lookups;
1686+
if (nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
1687+
return lookups;
1688+
}
16291689
}
16301690

1691+
// After cloning our common fragment, we use the “lookups” to cache live
1692+
// references to DOM nodes so that we can surgically perform updates later in
1693+
// an efficient manner. Lookups are like directions to find our real targets.
16311694
static #findTargets(fragment, lookups) {
16321695
const targets = [];
16331696
const cache = new Map();
@@ -1659,6 +1722,7 @@ class TemplateEngine {
16591722
return targets;
16601723
}
16611724

1725+
// Create and prepare a document fragment to be injected into some container.
16621726
static #ready(result) {
16631727
if (result.readied) {
16641728
throw new Error(`Unexpected re-injection of template result.`);
@@ -1678,16 +1742,6 @@ class TemplateEngine {
16781742
Object.assign(result, { fragment, entries });
16791743
}
16801744

1681-
static #inject(result, node, options) {
1682-
const nodes = result.type === 'svg'
1683-
? result.fragment.firstChild.childNodes
1684-
: result.fragment.childNodes;
1685-
options?.before
1686-
? TemplateEngine.#insertAllBefore(node.parentNode, node, nodes)
1687-
: TemplateEngine.#insertAllBefore(node, null, nodes);
1688-
result.fragment = null;
1689-
}
1690-
16911745
static #assign(result, newResult) {
16921746
result.lastValues = result.values;
16931747
result.values = newResult.values;
@@ -1845,6 +1899,8 @@ class TemplateEngine {
18451899
}
18461900
}
18471901

1902+
// Bind the current values from a result by walking through each target and
1903+
// updating the DOM if things have changed.
18481904
static #commit(result) {
18491905
result.lastValues ??= result.values.map(() => TemplateEngine.#UNSET);
18501906
const { entries, values, lastValues } = result;
@@ -1862,6 +1918,18 @@ class TemplateEngine {
18621918
}
18631919
}
18641920

1921+
// Attach a document fragment into some container. Note that all the DOM in
1922+
// the fragment will already have values correctly bound.
1923+
static #inject(result, node, options) {
1924+
const nodes = result.type === 'svg'
1925+
? result.fragment.firstChild.childNodes
1926+
: result.fragment.childNodes;
1927+
options?.before
1928+
? TemplateEngine.#insertAllBefore(node.parentNode, node, nodes)
1929+
: TemplateEngine.#insertAllBefore(node, null, nodes);
1930+
result.fragment = null;
1931+
}
1932+
18651933
static #throwUpdaterError(updater, type) {
18661934
switch (updater) {
18671935
case TemplateEngine.#live:

0 commit comments

Comments
 (0)