Skip to content

Commit 44071ad

Browse files
committed
[Fix] component: out-replace ordering
1 parent 354a5b0 commit 44071ad

3 files changed

Lines changed: 183 additions & 9 deletions

File tree

src/js/select/ui/components.js

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,20 @@ class UITemplateSlot {
386386
return count ? res : null;
387387
}
388388

389+
static ComparePathDesc(a, b) {
390+
const ap = a?.path || [];
391+
const bp = b?.path || [];
392+
const n = Math.max(ap.length, bp.length);
393+
for (let i = 0; i < n; i++) {
394+
const av = ap[i] ?? -1;
395+
const bv = bp[i] ?? -1;
396+
if (av !== bv) {
397+
return bv - av;
398+
}
399+
}
400+
return bp.length - ap.length;
401+
}
402+
389403
// Computes path indices from `parent` to `node`.
390404
static Path(node, parent, path) {
391405
const res = [];
@@ -1951,27 +1965,48 @@ class UIInstance {
19511965
}
19521966

19531967
// Compiles slot definitions into efficient applier functions.
1954-
static _compileSlotApplier(slots, rawSingle = false) {
1968+
static _compileSlotApplier(slots, rawSingle = false, stableDomOrder = false) {
19551969
if (!slots) {
19561970
return null;
19571971
}
19581972
const keys = [];
19591973
const groups = [];
1974+
const plan = [];
19601975
for (const key in slots) {
19611976
keys.push(key);
1962-
groups.push(slots[key]);
1977+
const group = slots[key];
1978+
groups.push(group);
1979+
for (let j = 0; j < group.length; j++) {
1980+
plan.push({ key, keyIndex: keys.length - 1, itemIndex: j, slot: group[j] });
1981+
}
19631982
}
19641983
if (keys.length === 0) {
19651984
return null;
19661985
}
1986+
if (stableDomOrder) {
1987+
plan.sort((a, b) => UITemplateSlot.ComparePathDesc(a.slot, b.slot));
1988+
}
19671989
return (nodes, parent) => {
19681990
const res = {};
1991+
const mappedGroups = new Array(keys.length);
19691992
for (let i = 0; i < keys.length; i++) {
1970-
const source = groups[i];
1971-
const mapped = new Array(source.length);
1972-
for (let j = 0; j < source.length; j++) {
1973-
mapped[j] = source[j].apply(nodes, parent, rawSingle);
1993+
mappedGroups[i] = new Array(groups[i].length);
1994+
}
1995+
if (stableDomOrder) {
1996+
for (let i = 0; i < plan.length; i++) {
1997+
const { keyIndex, itemIndex, slot } = plan[i];
1998+
mappedGroups[keyIndex][itemIndex] = slot.apply(nodes, parent, rawSingle);
1999+
}
2000+
} else {
2001+
for (let i = 0; i < keys.length; i++) {
2002+
const source = groups[i];
2003+
for (let j = 0; j < source.length; j++) {
2004+
mappedGroups[i][j] = source[j].apply(nodes, parent, rawSingle);
2005+
}
19742006
}
2007+
}
2008+
for (let i = 0; i < keys.length; i++) {
2009+
const mapped = mappedGroups[i];
19752010
res[keys[i]] = rawSingle && mapped.length === 1 ? mapped[0] : mapped;
19762011
}
19772012
return res;
@@ -1982,9 +2017,9 @@ class UIInstance {
19822017
if (template._compiledSlotAppliers) {
19832018
return template._compiledSlotAppliers;
19842019
}
1985-
template._compiledSlotAppliers = {
1986-
in: UIInstance._compileSlotApplier(template.in),
1987-
out: UIInstance._compileSlotApplier(template.out),
2020+
template._compiledSlotAppliers = {
2021+
in: UIInstance._compileSlotApplier(template.in),
2022+
out: UIInstance._compileSlotApplier(template.out, false, true),
19882023
inout: UIInstance._compileSlotApplier(template.inout),
19892024
ref: UIInstance._compileSlotApplier(template.ref, true),
19902025
on: UIInstance._compileSlotApplier(template.on),

src/js/select/ui/factory.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { COMPONENTS, component as componentRegistry, uiOptions, UITemplate } fro
2424
const TEMPLATE_RESOURCES = new Map()
2525
const TEMPLATE_RESOURCE_LOADS = new Map()
2626
const TEMPLATE_NAME_STACKS = new Map()
27+
const STYLE_RESOURCES = new Map()
2728

2829
function cloneSubs(subs) {
2930
if (!subs) {
@@ -244,6 +245,9 @@ function getTemplateResource(ref, scope = document) {
244245
async function load(url, scope = document) {
245246
const parsed = parseResourceReference(url, scope)
246247
const resourceURL = parsed?.url ?? normalizeResourceURL(url, scope)
248+
if (STYLE_RESOURCES.has(resourceURL)) {
249+
return STYLE_RESOURCES.get(resourceURL)
250+
}
247251
if (TEMPLATE_RESOURCES.has(resourceURL)) {
248252
return TEMPLATE_RESOURCES.get(resourceURL)
249253
}
@@ -256,6 +260,16 @@ async function load(url, scope = document) {
256260
throw new Error(`ui.load(): unable to load ${resourceURL} (${response.status} ${response.statusText})`)
257261
}
258262
const source = await response.text()
263+
if (/\.css(?:\?|$)/i.test(resourceURL)) {
264+
const style = document.createElement("style")
265+
style.setAttribute("data-ui-load", resourceURL)
266+
style.textContent = source
267+
const target = scope?.head || document.head || document.documentElement || document.body
268+
target.appendChild(style)
269+
const resource = { url: resourceURL, type: "css", node: style }
270+
STYLE_RESOURCES.set(resourceURL, resource)
271+
return resource
272+
}
259273
const doc = HTML.parseFromString(source, "text/html")
260274
pruneTemplateWhitespace(doc.body)
261275
const nodes = [...doc.body.childNodes]

tests/ui-out-replace-shape.test.js

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { Window } from "happy-dom";
4+
5+
function setupGlobals(window) {
6+
window.SyntaxError = SyntaxError;
7+
window.TypeError = TypeError;
8+
window.Error = Error;
9+
const g = globalThis;
10+
g.window = window;
11+
g.document = window.document;
12+
g.Node = window.Node;
13+
g.Element = window.Element;
14+
g.HTMLElement = window.HTMLElement;
15+
g.DocumentFragment = window.DocumentFragment;
16+
g.Text = window.Text;
17+
g.Comment = window.Comment;
18+
g.Document = window.Document;
19+
g.DOMParser = window.DOMParser;
20+
g.MutationObserver = window.MutationObserver;
21+
g.CustomEvent = window.CustomEvent;
22+
g.Event = window.Event;
23+
g.MouseEvent = window.MouseEvent;
24+
g.KeyboardEvent = window.KeyboardEvent;
25+
g.NodeFilter = window.NodeFilter;
26+
g.SVGElement = window.SVGElement;
27+
g.customElements = window.customElements;
28+
g.requestAnimationFrame = window.requestAnimationFrame.bind(window);
29+
g.cancelAnimationFrame = window.cancelAnimationFrame.bind(window);
30+
g.navigator = window.navigator;
31+
g.getComputedStyle = window.getComputedStyle.bind(window);
32+
const styleProto = Object.getPrototypeOf(
33+
window.document.createElement("div").style,
34+
);
35+
if (styleProto && !styleProto[Symbol.iterator]) {
36+
Object.defineProperty(styleProto, Symbol.iterator, {
37+
configurable: true,
38+
value: function* iter() {
39+
for (const key of Object.keys(this)) {
40+
if (/^[a-zA-Z-]+$/.test(key)) {
41+
yield key;
42+
}
43+
}
44+
},
45+
});
46+
}
47+
}
48+
49+
describe("ui out-replace DOM shape", () => {
50+
test("adjacent out-replace siblings preserve the expected direct child shape", async () => {
51+
const window = new Window({ url: "http://localhost:8000/repro" });
52+
setupGlobals(window);
53+
const { ui } = await import("../src/js/select/ui.js");
54+
55+
document.body.innerHTML = `
56+
<div id="app"></div>
57+
<template id="AdjacentOutReplaceRepro">
58+
<div class="horizontal">
59+
<div out-replace=".|One"></div>
60+
<div out-replace=".|Two"></div>
61+
<div out-replace=".|Three"></div>
62+
</div>
63+
</template>
64+
<template name="One"><div class="one">One</div></template>
65+
<template name="Two"><div class="two">Two</div></template>
66+
<template name="Three"><div class="three">Three</div></template>
67+
`;
68+
69+
const Repro = ui("AdjacentOutReplaceRepro");
70+
ui("One");
71+
ui("Two");
72+
ui("Three");
73+
74+
const instance = Repro.new().mount("#app");
75+
const horizontal = document.querySelector("#app .horizontal");
76+
const classes = Array.from(horizontal.children).map((_) => _.className);
77+
78+
expect(horizontal.children.length).toBe(3);
79+
expect(classes).toEqual(["One one", "Two two", "Three three"]);
80+
81+
instance.unmount();
82+
document.body.innerHTML = "";
83+
window.close?.();
84+
});
85+
86+
test("wrapped out siblings preserve the expected direct child shape", async () => {
87+
const window = new Window({ url: "http://localhost:8000/repro" });
88+
setupGlobals(window);
89+
const { ui } = await import("../src/js/select/ui.js");
90+
91+
document.body.innerHTML = `
92+
<div id="app"></div>
93+
<template id="WrappedOutRepro">
94+
<div class="horizontal">
95+
<div class="slot" out=".|One"></div>
96+
<div class="slot" out=".|Two"></div>
97+
<div class="slot" out=".|Three"></div>
98+
</div>
99+
</template>
100+
<template name="One"><div class="one">One</div></template>
101+
<template name="Two"><div class="two">Two</div></template>
102+
<template name="Three"><div class="three">Three</div></template>
103+
`;
104+
105+
const Repro = ui("WrappedOutRepro");
106+
ui("One");
107+
ui("Two");
108+
ui("Three");
109+
110+
const instance = Repro.new().mount("#app");
111+
const horizontal = document.querySelector("#app .horizontal");
112+
const classes = Array.from(horizontal.children).map((_) => _.className);
113+
const nested = Array.from(horizontal.children).map(
114+
(_) => _.firstElementChild?.className ?? null,
115+
);
116+
117+
expect(horizontal.children.length).toBe(3);
118+
expect(classes).toEqual(["slot", "slot", "slot"]);
119+
expect(nested).toEqual(["One one", "Two two", "Three three"]);
120+
121+
instance.unmount();
122+
document.body.innerHTML = "";
123+
window.close?.();
124+
});
125+
});

0 commit comments

Comments
 (0)