Skip to content

Commit 88fd4c5

Browse files
authored
no wrapper span (#1439)
1 parent 47fb7ef commit 88fd4c5

36 files changed

+249
-105
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"d3-hierarchy": "^3.1.2",
6969
"esbuild": "^0.20.1",
7070
"fast-array-diff": "^1.1.0",
71+
"fast-deep-equal": "^3.1.3",
7172
"gray-matter": "^4.0.3",
7273
"he": "^1.2.0",
7374
"highlight.js": "^11.8.0",
@@ -118,7 +119,6 @@
118119
"eslint-config-prettier": "^9.1.0",
119120
"eslint-import-resolver-typescript": "^3.6.1",
120121
"eslint-plugin-import": "^2.29.0",
121-
"fast-deep-equal": "^3.1.3",
122122
"glob": "^10.3.10",
123123
"mocha": "^10.2.0",
124124
"prettier": "^3.0.3 <3.1",

src/client/main.js

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,19 @@ export const runtime = new Runtime(library);
2020
export const main = runtime.module();
2121

2222
const cellsById = new Map();
23+
const rootsById = findRoots(document.body);
2324

2425
export function define(cell) {
2526
const {id, inline, inputs = [], outputs = [], body} = cell;
2627
const variables = [];
27-
cellsById.get(id)?.variables.forEach((v) => v.delete());
2828
cellsById.set(id, {cell, variables});
29-
const root = document.querySelector(`#cell-${id}`);
30-
const loading = root.querySelector(".observablehq-loading");
29+
const root = rootsById.get(id);
30+
const loading = findLoading(root);
31+
root._nodes = [];
32+
if (loading) root._nodes.push(loading);
3133
const pending = () => reset(root, loading);
3234
const rejected = (error) => reject(root, error);
33-
const v = main.variable({_node: root, pending, rejected}, {shadow: {}}); // _node for visibility promise
35+
const v = main.variable({_node: root.parentNode, pending, rejected}, {shadow: {}}); // _node for visibility promise
3436
if (inputs.includes("display") || inputs.includes("view")) {
3537
let displayVersion = -1; // the variable._version of currently-displayed values
3638
const display = inline ? displayInline : displayBlock;
@@ -67,39 +69,91 @@ export function define(cell) {
6769
// original loading indicator in this case, which applies to inline expressions
6870
// and expression code blocks.
6971
function reset(root, loading) {
70-
if (root.classList.contains("observablehq--error")) {
71-
root.classList.remove("observablehq--error");
72+
if (root._error) {
73+
root._error = false;
7274
clear(root);
73-
if (loading) root.append(loading);
75+
if (loading) displayNode(root, loading);
7476
}
7577
}
7678

7779
function reject(root, error) {
7880
console.error(error);
79-
root.classList.add("observablehq--error"); // see reset
81+
root._error = true; // see reset
8082
clear(root);
81-
root.append(inspectError(error));
83+
displayNode(root, inspectError(error));
84+
}
85+
86+
function displayNode(root, node) {
87+
if (node.nodeType === 11) {
88+
let child;
89+
while ((child = node.firstChild)) {
90+
root._nodes.push(child);
91+
root.parentNode.insertBefore(child, root);
92+
}
93+
} else {
94+
root._nodes.push(node);
95+
root.parentNode.insertBefore(node, root);
96+
}
8297
}
8398

8499
function clear(root) {
85-
root.textContent = "";
100+
for (const v of root._nodes) v.remove();
101+
root._nodes.length = 0;
86102
}
87103

88104
function displayInline(root, value) {
89-
if (isNode(value) || typeof value === "string" || !value?.[Symbol.iterator]) root.append(value);
90-
else root.append(...value);
105+
if (isNode(value)) {
106+
displayNode(root, value);
107+
} else if (typeof value === "string" || !value?.[Symbol.iterator]) {
108+
displayNode(root, document.createTextNode(value));
109+
} else {
110+
for (const v of value) {
111+
displayNode(root, isNode(v) ? v : document.createTextNode(v));
112+
}
113+
}
91114
}
92115

93116
function displayBlock(root, value) {
94-
root.append(isNode(value) ? value : inspect(value));
117+
displayNode(root, isNode(value) ? value : inspect(value));
95118
}
96119

97120
export function undefine(id) {
98-
cellsById.get(id)?.variables.forEach((v) => v.delete());
121+
clear(rootsById.get(id));
122+
cellsById.get(id).variables.forEach((v) => v.delete());
99123
cellsById.delete(id);
100124
}
101125

102126
// Note: Element.prototype is instanceof Node, but cannot be inserted!
103127
function isNode(value) {
104128
return value instanceof Node && value instanceof value.constructor;
105129
}
130+
131+
export function findRoots(root) {
132+
const roots = new Map();
133+
const iterator = document.createNodeIterator(root, 128, null);
134+
let node;
135+
while ((node = iterator.nextNode())) {
136+
if (isRoot(node)) {
137+
roots.set(node.data.slice(1, -1), node);
138+
}
139+
}
140+
return roots;
141+
}
142+
143+
function isRoot(node) {
144+
return node.nodeType === 8 && /^:[0-9a-f]{8}(?:-\d+)?:$/.test(node.data);
145+
}
146+
147+
function isLoading(node) {
148+
return node.nodeType === 1 && node.tagName === "OBSERVABLEHQ-LOADING";
149+
}
150+
151+
export function findLoading(root) {
152+
const sibling = root.previousSibling;
153+
return sibling && isLoading(sibling) ? sibling : null;
154+
}
155+
156+
export function registerRoot(id, node) {
157+
if (node == null) rootsById.delete(id);
158+
else rootsById.set(id, node);
159+
}

src/client/preview.js

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {FileAttachment, registerFile} from "npm:@observablehq/stdlib";
22
import {main, runtime, undefine} from "./main.js";
3+
import {findLoading, findRoots, registerRoot} from "./main.js";
34
import {enableCopyButtons} from "./pre.js";
45

56
export * from "./index.js";
@@ -48,12 +49,30 @@ export function open({hash, eval: compile} = {}) {
4849
case "add": {
4950
for (const item of items) {
5051
const pos = oldPos + offset;
51-
if (pos < root.children.length) {
52-
root.children[pos].insertAdjacentHTML("beforebegin", item);
52+
if (pos < root.childNodes.length) {
53+
const child = root.childNodes[pos];
54+
if (item.type === 1) {
55+
if (child.nodeType === 1) {
56+
child.insertAdjacentHTML("beforebegin", item.value);
57+
} else {
58+
root.insertAdjacentHTML("beforeend", item.value);
59+
root.insertBefore(root.lastChild, child);
60+
}
61+
} else if (item.type === 3) {
62+
root.insertBefore(document.createTextNode(item.value), child);
63+
} else if (item.type === 8) {
64+
root.insertBefore(document.createComment(item.value), child);
65+
}
5366
} else {
54-
root.insertAdjacentHTML("beforeend", item);
67+
if (item.type === 1) {
68+
root.insertAdjacentHTML("beforeend", item.value);
69+
} else if (item.type === 3) {
70+
root.appendChild(document.createTextNode(item.value));
71+
} else if (item.type === 8) {
72+
root.appendChild(document.createComment(item.value));
73+
}
5574
}
56-
indexCells(addedCells, root.children[pos]);
75+
indexCells(addedCells, root.childNodes[pos]);
5776
++offset;
5877
}
5978
break;
@@ -62,26 +81,35 @@ export function open({hash, eval: compile} = {}) {
6281
let removes = 0;
6382
for (let i = 0; i < items.length; ++i) {
6483
const pos = oldPos + offset;
65-
if (pos < root.children.length) {
66-
const child = root.children[pos];
84+
if (pos < root.childNodes.length) {
85+
const child = root.childNodes[pos];
6786
indexCells(removedCells, child);
6887
child.remove();
6988
++removes;
7089
} else {
71-
console.error(`remove out of range: ${pos}${root.children.length}`);
90+
console.error(`remove out of range: ${pos}${root.childNodes.length}`);
7291
}
7392
}
7493
offset -= removes;
7594
break;
7695
}
7796
}
7897
}
79-
for (const [id, removed] of removedCells) {
80-
addedCells.get(id)?.replaceWith(removed);
81-
}
8298
for (const id of message.code.removed) {
8399
undefine(id);
84100
}
101+
for (const [id, removed] of removedCells) {
102+
if (!addedCells.has(id)) {
103+
registerRoot(id, null);
104+
} else {
105+
replaceRoot(addedCells.get(id), removed);
106+
}
107+
}
108+
for (const [id, root] of addedCells) {
109+
if (!removedCells.has(id)) {
110+
registerRoot(id, root);
111+
}
112+
}
85113
for (const body of message.code.added) {
86114
compile(body);
87115
}
@@ -138,11 +166,8 @@ export function open({hash, eval: compile} = {}) {
138166
};
139167

140168
function indexCells(map, node) {
141-
if (node.id.startsWith("cell-")) {
142-
map.set(node.id, node);
143-
}
144-
for (const cell of node.querySelectorAll("[id^=cell-]")) {
145-
map.set(cell.id, cell);
169+
for (const [id, root] of findRoots(node)) {
170+
map.set(id, root);
146171
}
147172
}
148173

@@ -151,3 +176,11 @@ export function open({hash, eval: compile} = {}) {
151176
socket.send(JSON.stringify(message));
152177
}
153178
}
179+
180+
export function replaceRoot(added, removed) {
181+
findLoading(added)?.remove();
182+
added.replaceWith(removed);
183+
for (const n of removed._nodes) {
184+
removed.parentNode.insertBefore(n, removed);
185+
}
186+
}

src/html.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ export function rewriteHtml(
169169
? hljs.highlight(child.textContent!, {language}).value
170170
: isElement(child)
171171
? child.outerHTML
172+
: isComment(child)
173+
? `<!--${he.escape(child.data)}-->`
172174
: "";
173175
}
174176
code.innerHTML = html;
@@ -183,6 +185,29 @@ export function rewriteHtml(
183185
h.append(a);
184186
}
185187

188+
// For incremental update during preview, we need to know the direct children
189+
// of the body statically; therefore we must wrap any top-level cells with a
190+
// span to avoid polluting the direct children with dynamic content.
191+
for (let child = document.body.firstChild; child; child = child.nextSibling) {
192+
if (isRoot(child)) {
193+
const parent = document.createElement("span");
194+
const loading = findLoading(child);
195+
child.replaceWith(parent);
196+
if (loading) parent.appendChild(loading);
197+
parent.appendChild(child);
198+
child = parent;
199+
}
200+
}
201+
202+
// In some contexts, such as a table, the <observablehq-loading> element may
203+
// be reparented; enforce the requirement that the <observablehq-loading>
204+
// element immediately precedes its root by removing any violating elements.
205+
for (const l of document.querySelectorAll("observablehq-loading")) {
206+
if (!l.nextSibling || !isRoot(l.nextSibling)) {
207+
l.remove();
208+
}
209+
}
210+
186211
return document.body.innerHTML;
187212
}
188213

@@ -208,14 +233,31 @@ function resolveSrcset(srcset: string, resolve: (specifier: string) => string):
208233
.join(", ");
209234
}
210235

211-
function isText(node: Node): node is Text {
236+
export function isText(node: Node): node is Text {
212237
return node.nodeType === 3;
213238
}
214239

215-
function isElement(node: Node): node is Element {
240+
export function isComment(node: Node): node is Comment {
241+
return node.nodeType === 8;
242+
}
243+
244+
export function isElement(node: Node): node is Element {
216245
return node.nodeType === 1;
217246
}
218247

248+
function isRoot(node: Node): node is Comment {
249+
return isComment(node) && /^:[0-9a-f]{8}(?:-\d+)?:$/.test(node.data);
250+
}
251+
252+
function isLoading(node: Node): node is Element {
253+
return isElement(node) && node.tagName === "OBSERVABLEHQ-LOADING";
254+
}
255+
256+
function findLoading(node: Node): Element | null {
257+
const sibling = node.previousSibling;
258+
return sibling && isLoading(sibling) ? sibling : null;
259+
}
260+
219261
/**
220262
* Denotes a string that contains HTML source; when interpolated into an html
221263
* tagged template literal, it will not be escaped. Use Html.unsafe to denote

src/markdown.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,9 @@ function makeFenceRenderer(baseRenderer: RenderRule): RenderRule {
106106
// TODO const sourceLine = context.startLine + context.currentLine;
107107
const node = parseJavaScript(source, {path});
108108
context.code.push({id, node});
109-
html += `<div id="cell-${id}" class="observablehq observablehq--block">${
110-
node.expression ? '<span class="observablehq-loading"></span>' : ""
111-
}</div>\n`;
109+
html += `<div class="observablehq observablehq--block">${
110+
node.expression ? "<observablehq-loading></observablehq-loading>" : ""
111+
}<!--:${id}:--></div>\n`;
112112
}
113113
} catch (error) {
114114
if (!(error instanceof SyntaxError)) throw error;
@@ -261,14 +261,12 @@ function makePlaceholderRenderer(): RenderRule {
261261
// TODO sourceLine: context.startLine + context.currentLine
262262
const node = parseJavaScript(token.content, {path, inline: true});
263263
context.code.push({id, node});
264-
return `<span id="cell-${id}"><span class="observablehq-loading"></span></span>`;
264+
return `<observablehq-loading></observablehq-loading><!--:${id}:-->`;
265265
} catch (error) {
266266
if (!(error instanceof SyntaxError)) throw error;
267-
return `<span id="cell-${id}">
268-
<span class="observablehq--inspect observablehq--error" style="display: block;">SyntaxError: ${he.escape(
269-
error.message
270-
)}</span>
271-
</span>`;
267+
return `<span class="observablehq--inspect observablehq--error" style="display: block;">SyntaxError: ${he.escape(
268+
error.message
269+
)}</span>`;
272270
}
273271
};
274272
}

0 commit comments

Comments
 (0)