Skip to content
219 changes: 147 additions & 72 deletions packages/renderer/src/engines/native/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,134 @@ function cachedBuildHTMLString(ast, options) {
return entry[slot];
}

function getBuildSlot(ast, isSVG) {
const entry = buildStringCache.get(ast);
if (!entry) { return null; }
return entry[isSVG ? 'svg' : 'html'];
}

// Binds a comment-position marker: a block or expression via bindBlock, a
// rawText marker via its registered block. Shared by walkAndBind and
// replayBindingPlan so a new comment type is added in one place.
function bindCommentMarker(node, type, markerID, entries, data, scope, renderer) {
const entry = entries[markerID];
if (type === 'expression' || type === 'block') {
renderer.bindBlock(node, entry, data, scope);
}
else if (type === 'rawText') {
getBlock('rawText')?.({ comment: node, entry, data, scope, renderer });
}
}

// Walks a cloned fragment and binds every marker: attributes inline,
// comments deferred until after the walk because their dispatch can replace
// or remove the comment node, which would invalidate a live walker.
//
// With `buildPlan` set it also records an ordered plan of
// `{type, nodeIndex, ...payload}` entries that `replayBindingPlan` reuses on
// later clones, skipping the per-clone re-walk, attribute re-parse, and
// marker-predicate matching. See `bindMarkers` for why the plan is built on
// the second render of an AST, not the first.
function walkAndBind(root, entries, data, scope, renderer, buildPlan) {
const plan = buildPlan ? [] : null;
const processedAttrIDs = new Set();
const commentsToProcess = [];
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT,
);
let nodeIndex = 0;
let node;
while ((node = walker.nextNode())) {
nodeIndex++;
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node;
// Collect into an array before binding: bindAttributeExpression can
// removeAttribute for property and event bindings, which would mutate
// the live NamedNodeMap if we bound while iterating it.
let attrsToProcess;
for (let i = 0; i < element.attributes.length; i++) {
const attr = element.attributes[i];
if (attr.value.includes(ATTR_MARKER_PREFIX)) {
(attrsToProcess ??= []).push({ name: attr.name, value: attr.value });
}
}
if (attrsToProcess) {
for (const { name: attrName, value: attrValue } of attrsToProcess) {
const { parts, markerIDs } = parseAttributePartsFn(attrValue);
for (const id of markerIDs) { processedAttrIDs.add(id); }
if (plan) { plan.push({ type: 'attr', nodeIndex, attrName, parts }); }
renderer.bindAttributeExpression(element, attrName, parts, entries, data, scope);
}
}
}
else {
const text = node.data;
let markerID;
let type;
if (isExpressionMarker(text)) {
markerID = parseExpressionID(text);
type = 'expression';
}
else if (isRawTextMarker(text)) {
markerID = parseRawTextID(text);
type = 'rawText';
}
else if (isBlockOpen(text)) {
markerID = parseBlockOpenID(text);
type = 'block';
}
if (type && !isNaN(markerID)) {
commentsToProcess.push({ comment: node, markerID, type, nodeIndex });
}
}
}
for (const { comment, markerID, type, nodeIndex: commentNodeIndex } of commentsToProcess) {
// Safe to dedup here: tree order visits an element before any sibling
// comment, so an attr marker's owner is recorded before a comment that
// shares its ID.
if (type === 'expression' && processedAttrIDs.has(markerID)) { continue; }
if (plan) { plan.push({ type, nodeIndex: commentNodeIndex, markerID }); }
bindCommentMarker(comment, type, markerID, entries, data, scope, renderer);
}
if (plan) {
// Attrs are recorded during the walk and comments after it, so the plan
// holds two runs already in nodeIndex order. One sort merges them into
// the single traversal order replay walks in.
plan.sort((a, b) => a.nodeIndex - b.nodeIndex);
}
return plan;
}

// Replays the cached plan against a fresh clone on later renders. Single
// walker advanced by the recorded nodeIndex, attrs dispatched inline,
// comments deferred for the same walker-invalidation reason as walkAndBind.
function replayBindingPlan(root, plan, entries, data, scope, renderer) {
if (plan.length === 0) { return; }
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT,
);
let cursor = 0;
const deferredComments = [];
for (const step of plan) {
while (cursor < step.nodeIndex) {
walker.nextNode();
cursor++;
}
const node = walker.currentNode;
if (step.type === 'attr') {
renderer.bindAttributeExpression(node, step.attrName, step.parts, entries, data, scope);
}
else {
deferredComments.push({ node, type: step.type, markerID: step.markerID });
}
}
for (const { node, type, markerID } of deferredComments) {
bindCommentMarker(node, type, markerID, entries, data, scope, renderer);
}
}

export class Renderer {
static nextId = 0;
constructor(
Expand Down Expand Up @@ -151,7 +279,7 @@ export class Renderer {
const fragment = this.parseHTML(htmlString, isSVG);

// Phase 3: Walk the DOM tree, find markers, wire bindings
this.bindMarkers(fragment, entries, data, scope, ast);
this.bindMarkers(fragment, entries, data, scope, ast, isSVG);

return fragment;
}
Expand Down Expand Up @@ -202,79 +330,26 @@ export class Renderer {
bindAttribute({ element, attrName, parts, entries, data, scope, renderer: this, skipFirstWrite });
}

bindMarkers(root, entries, data, scope, ast) {
bindMarkers(root, entries, data, scope, ast, isSVG = this.isSVG) {
if (entries.length === 0) { return; }

// Single-pass walker over SHOW_ELEMENT | SHOW_COMMENT. Attribute
// processing is safe inline (only touches the element's own
// attributes); comment processing is deferred because it mutates the
// tree structure (replace/remove) and would invalidate the live walker.
const processedAttrIDs = new Set();
const commentsToProcess = [];
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT,
);
let node;
while ((node = walker.nextNode())) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node;
// Most elements have zero __sui attrs — defer the array allocation
// until we find one. Collect first, then iterate, because
// bindAttributeExpression calls element.removeAttribute for property
// and event bindings, which mutates the live NamedNodeMap.
let attrsToProcess;
for (let i = 0; i < element.attributes.length; i++) {
const attr = element.attributes[i];
if (attr.value.includes(ATTR_MARKER_PREFIX)) {
(attrsToProcess ??= []).push({ name: attr.name, value: attr.value });
}
}
if (attrsToProcess) {
for (const { name: attrName, value: attrValue } of attrsToProcess) {
const { parts, markerIDs } = parseAttributePartsFn(attrValue);
for (const id of markerIDs) { processedAttrIDs.add(id); }
this.bindAttributeExpression(element, attrName, parts, entries, data, scope);
}
}
}
else {
const text = node.data;
if (isExpressionMarker(text)) {
const markerID = parseExpressionID(text);
if (!isNaN(markerID)) {
// Filter deferred until after the walk: elements visit before
// sibling comments in document order, so an attr-marker's
// owning element is always recorded before any comment that
// would share its ID.
commentsToProcess.push({ comment: node, markerID, type: 'expression' });
}
}
else if (isRawTextMarker(text)) {
const markerID = parseRawTextID(text);
if (!isNaN(markerID)) {
commentsToProcess.push({ comment: node, markerID, type: 'rawText' });
}
}
else if (isBlockOpen(text)) {
const markerID = parseBlockOpenID(text);
if (!isNaN(markerID)) {
commentsToProcess.push({ comment: node, markerID, type: 'block' });
}
}
}
// `isSVG` mirrors `readAST`'s per-call override so the slot lookup matches
// the slot `cachedBuildHTMLString` populated.
const slot = ast ? getBuildSlot(ast, isSVG) : null;
if (slot && slot.plan) {
replayBindingPlan(root, slot.plan, entries, data, scope, this);
return;
}

for (const { comment, markerID, type } of commentsToProcess) {
if (type === 'expression' && processedAttrIDs.has(markerID)) { continue; }
const entry = entries[markerID];

if (type === 'expression' || type === 'block') {
this.bindBlock(comment, entry, data, scope);
}
else if (type === 'rawText') {
getBlock('rawText')?.({ comment, entry, data, scope, renderer: this });
}
// Build the plan on the second render of an AST, never the first. A
// component embedded exactly once is common, and building on render 1
// makes that case slower than the old single pass: it pays the plan
// allocation and sort for a plan nothing ever replays. Deferring to
// render 2 keeps a single-use AST at the original walk cost, and only
// ASTs actually cloned more than once pay to cache.
const buildPlan = slot ? slot.hasRendered : false;
const plan = walkAndBind(root, entries, data, scope, this, buildPlan);
if (slot) {
if (buildPlan) { slot.plan = plan; }
else { slot.hasRendered = true; }
}
}

Expand Down
Loading