Skip to content

Commit 855a5fc

Browse files
authored
Bug/Refactor: Fix Blocks In Attribute Values (#196)
1 parent 87e62d8 commit 855a5fc

21 files changed

Lines changed: 1568 additions & 709 deletions

docs/src/components/SpecimenExplorer/SpecimenExplorer.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
<ui-panel size="280px" min-size="200px" max-size="400px">
3131
<ui-panels direction="vertical" class="control-panels">
3232
{#each group in getControlGroups}
33-
<ui-panel size="{#if true}grow{/if}" label="{group.label}">
33+
<ui-panel size="{#if group.last}grow{else}natural{/if}" label="{group.label}">
3434
<div class="section-controls">
3535
{#each control in group.controls}
3636
{#if is control.type 'radio'}

packages/renderer/src/build-html-string.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,10 +267,19 @@ export function buildHTMLString(ast, { snippets = {}, isSVG: initialSVG = false
267267
rawTextNodes.push(node);
268268
break;
269269
}
270-
// Block-level directives: if, each, async, rerender, template
270+
// Attribute-position blocks emit the same __suiN__ shape as
271+
// attribute-position expressions so they share the attribute-
272+
// binding path — comment markers in an attribute value become
273+
// literal text, not Comment nodes.
271274
const id = entries.length;
272-
htmlString += `<!--${BLOCK_MARKER}${id}-->`;
273-
const entry = { id, type: node.type, node };
275+
const classification = analyzePosition(htmlBuffer);
276+
if (classification.insideTag) {
277+
htmlString += `${ATTR_MARKER_PREFIX}${id}${ATTR_MARKER_SUFFIX}`;
278+
}
279+
else {
280+
htmlString += `<!--${BLOCK_MARKER}${id}-->`;
281+
}
282+
const entry = { id, type: node.type, node, classification };
274283
if (insideSVG) { entry.isSVG = true; }
275284
entries.push(entry);
276285
break;

packages/renderer/src/engines/lit/directives/reactive-conditional.js

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { Reaction } from '@semantic-ui/reactivity';
2-
import { each, isArray, isClient, isObject } from '@semantic-ui/utils';
2+
import { each, isClient } from '@semantic-ui/utils';
33
import { noChange, nothing } from 'lit';
44
import { AsyncDirective } from 'lit/async-directive.js';
55
import { directive, PartType } from 'lit/directive.js';
66

7+
import { serializeContent } from './serialize-content.js';
8+
79
export class ReactiveConditionalDirective extends AsyncDirective {
810
constructor(partInfo) {
911
super(partInfo);
@@ -20,12 +22,20 @@ export class ReactiveConditionalDirective extends AsyncDirective {
2022
}
2123

2224
let content = nothing;
25+
let initialFormatted = nothing;
2326
let context = {
2427
message: `if/else statement: {#if ${conditional.expression}}`,
2528
conditional: conditional,
2629
};
2730

28-
// Create a new reaction that watches for reactive changes on client
31+
// Create a new reaction that watches for reactive changes on client.
32+
// formatForPart is invoked INSIDE the reaction (even on firstRun) so
33+
// its serialization reads — which call .value() on inner directive
34+
// markers — register signal deps on this reaction. Without that,
35+
// inner expressions in a branch never propagate signal changes
36+
// through this directive's setValue.
37+
// matchIndex gating is intentionally absent — content can change
38+
// within the same branch when inner expressions update.
2939
if (isClient) {
3040
this.reaction = Reaction.create((comp) => {
3141
if (!this.isConnected) {
@@ -34,21 +44,25 @@ export class ReactiveConditionalDirective extends AsyncDirective {
3444
}
3545

3646
const result = this.getBranch(this.conditional);
37-
const matchIndex = result.matchIndex;
47+
this.matchIndex = result.matchIndex;
3848
content = result.content;
39-
if (!comp.firstRun && this.matchIndex !== matchIndex) {
40-
this.matchIndex = matchIndex;
41-
this.setValue(content);
49+
const formatted = this.formatForPart(content);
50+
if (comp.firstRun) {
51+
initialFormatted = formatted;
52+
}
53+
else {
54+
this.setValue(formatted);
4255
}
4356
return content;
4457
}, { context });
4558
}
4659
else {
4760
const result = this.getBranch(this.conditional);
4861
content = result.content;
62+
initialFormatted = this.formatForPart(content);
4963
}
5064

51-
return this.formatForPart(content);
65+
return initialFormatted;
5266
}
5367

5468
getBranch(conditional) {
@@ -87,43 +101,12 @@ export class ReactiveConditionalDirective extends AsyncDirective {
87101
switch (this.partInfo.type) {
88102
case PartType.ATTRIBUTE:
89103
case PartType.BOOLEAN_ATTRIBUTE:
90-
return this.serializeContent(content);
91-
92-
case PartType.CHILD:
93-
case PartType.PROPERTY:
94-
case PartType.EVENT:
95-
case PartType.ELEMENT:
104+
return serializeContent(content);
96105
default:
97-
// For element content, return as-is (TemplateResult objects are fine here)
98106
return content;
99107
}
100108
}
101109

102-
serializeContent(content) {
103-
// Handle lit's nothing value
104-
if (content === nothing) {
105-
return '';
106-
}
107-
108-
if (content?.strings) {
109-
// For simple conditionals in attributes, just join the static strings
110-
// This works for basic cases like {#if condition}text{/if}
111-
return content.strings.join('');
112-
}
113-
114-
// Handle arrays and objects like reactive-data does
115-
if (isArray(content) || isObject(content)) {
116-
try {
117-
return JSON.stringify(content);
118-
}
119-
catch (e) {
120-
return String(content);
121-
}
122-
}
123-
124-
return String(content);
125-
}
126-
127110
disconnected() {
128111
if (this.reaction) {
129112
this.reaction.stop();

packages/renderer/src/engines/lit/directives/reactive-rerender.js

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,77 @@
1-
import { noChange } from 'lit';
1+
import { noChange, nothing } from 'lit';
22
import { AsyncDirective } from 'lit/async-directive.js';
3-
import { directive } from 'lit/directive.js';
3+
import { directive, PartType } from 'lit/directive.js';
44

55
import { Reaction } from '@semantic-ui/reactivity';
66
import { isClient, wrapFunction } from '@semantic-ui/utils';
77

8+
import { serializeContent } from './serialize-content.js';
9+
810
export class ReactiveRerenderDirective extends AsyncDirective {
911
constructor(partInfo) {
1012
super(partInfo);
13+
this.partInfo = partInfo;
1114
this.reaction = null;
1215
}
1316

1417
render(condition) {
1518
this.condition = condition;
1619

17-
// Reuse existing reaction — signals handle updates
1820
if (this.reaction) {
1921
return noChange;
2022
}
2123

22-
// Create new reaction on client
23-
if (isClient) {
24-
this.watchChanges();
25-
}
26-
27-
return this.condition.content();
28-
}
29-
30-
watchChanges() {
24+
let initialFormatted = nothing;
3125
const context = {
3226
message: `rerender block: {#${this.condition.key ? 'guard' : 'rerender'} ${
3327
this.condition.keyString || this.condition.expressionString
3428
}}`,
3529
rerender: this.condition,
3630
};
3731

38-
this.reaction = Reaction.create((computation) => {
39-
if (!this.isConnected) {
40-
computation.stop();
41-
return;
42-
}
32+
if (isClient) {
33+
this.reaction = Reaction.create((computation) => {
34+
if (!this.isConnected) {
35+
computation.stop();
36+
return;
37+
}
38+
39+
// Outer guard/key reactivity.
40+
// {#guard expression} -> key=expression
41+
// {#rerender key=expression} -> key=expression
42+
if (this.condition.keyString) {
43+
Reaction.guard(() => this.getValue(this.condition.key()));
44+
}
45+
if (this.condition.expressionString) {
46+
this.getValue(this.condition.expression());
47+
}
4348

44-
// this guards against the return value of a reactive expression the "key"
45-
// {#guard expression} -> key=expression
46-
// {#rerender key=expression} -> key=expressin`
47-
if (this.condition.keyString) {
48-
Reaction.guard(() => this.getValue(this.condition.key()));
49-
}
49+
const formatted = this.formatForPart(this.condition.content());
50+
if (computation.firstRun) {
51+
initialFormatted = formatted;
52+
}
53+
else {
54+
this.setValue(formatted);
55+
}
56+
}, { context });
57+
}
58+
else {
59+
initialFormatted = this.formatForPart(this.condition.content());
60+
}
5061

51-
// {#rerender expression} - naively add a reactive context to this reaction
52-
if (this.condition.expressionString) {
53-
this.getValue(this.condition.expression());
54-
}
62+
return initialFormatted;
63+
}
5564

56-
if (!computation.firstRun) {
57-
this.setValue(this.condition.content());
58-
}
59-
}, { context });
65+
formatForPart(content) {
66+
switch (this.partInfo.type) {
67+
case PartType.ATTRIBUTE:
68+
case PartType.BOOLEAN_ATTRIBUTE:
69+
return serializeContent(content);
70+
default:
71+
return content;
72+
}
6073
}
6174

62-
// to make sure signal triggers reactivity
63-
// we want to call accessor from our reaction
6475
getValue(expression) {
6576
return wrapFunction(expression)();
6677
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { isArray, isFunction, isObject } from '@semantic-ui/utils';
2+
import { nothing } from 'lit';
3+
4+
// Serialize lit content (TemplateResult, primitive, array, object) for
5+
// attribute-position placement. Inner directive markers are resolved via
6+
// their first arg's `.value()` callback, which LitRenderer attaches in
7+
// evaluateExpression.
8+
9+
export function serializeContent(content) {
10+
if (content == null || content === nothing) { return ''; }
11+
if (content?.strings) {
12+
const { strings, values } = content;
13+
let result = '';
14+
for (let i = 0; i < strings.length; i++) {
15+
result += strings[i];
16+
if (i < values.length) {
17+
result += resolveValue(values[i]);
18+
}
19+
}
20+
return result;
21+
}
22+
if (isArray(content) || isObject(content)) {
23+
try {
24+
return JSON.stringify(content);
25+
}
26+
catch (e) {
27+
return String(content);
28+
}
29+
}
30+
return String(content);
31+
}
32+
33+
export function resolveValue(v) {
34+
if (isFunction(v?.values?.[0]?.value)) {
35+
return String(v.values[0].value() ?? '');
36+
}
37+
if (v?.strings) {
38+
return serializeContent(v);
39+
}
40+
if (isArray(v) || isObject(v)) {
41+
try {
42+
return JSON.stringify(v);
43+
}
44+
catch (e) {
45+
return String(v);
46+
}
47+
}
48+
return String(v ?? '');
49+
}

0 commit comments

Comments
 (0)