Skip to content

Commit a576de4

Browse files
committed
[Fix] ui/runtime: unwrap single reactive values before passing to component processors
When a component processor (e.g. `out="symbol|SymbolDetails"`) receives a single reactive cell as its input, the reactive wrapper was previously passed through unchanged to the component. This was inconsistent with starred (`*`) component processors, which already unwrap the reactive wrapper before iterating, and with function processors, which fully expand values before calling the function. The mismatch meant a component receiving a bare reactive cell could not reliably access named keys from its template data because `resolveSourceValue(data, key)` directly accesses `data[key]`, and reactive cells do not expose user properties on themselves. The fix unwraps the top-level reactive cell before handing the value to the component, matching the existing behaviour of the starred-component path. Multi-valued sourceMap bindings (`out="a:b,c:d|SymbolDetails"`) are unaffected because the top-level value is a plain object, not a reactive. This is a **breaking change** for any component that relied on receiving the reactive cell directly rather than its unwrapped inner value. Such components must either add an explicit `unwrap` processor (`out="key|unwrap|SymbolDetails"`) or extract `.value` from the reactive inside their behaviour functions. Changed: - src/js/select/ui/components/runtime.js: unwrap single reactive values in `applyNamedProcessor` before passing to component processors, consistent with the existing starred-component and function-processor paths. Added: - tests/ui-processor-reactives.test.js: three new tests covering unwrapping of single reactive values, preservation of non-reactive values, and regression guard confirming sourceMap bindings still pass reactive internals inside the mapped object. Updated: - tests/ui-reactive-props.tests.js: "passes reactive values through component processors" now expects the unwrapped value instead of the reactive cell. - docs/ref-ui.md: added note that single reactive values are unwrapped before reaching the component, matching the documented starred-component behaviour. No breaking changes for: sourceMap bindings (`out="a:b,c:d|Component"`), starred component processors (`out="items|*Component"`), function processors, event payloads (`on:click=...`), or template-mode out bindings.
1 parent 200e0a7 commit a576de4

4 files changed

Lines changed: 105 additions & 2 deletions

File tree

docs/ref-ui.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,7 @@ Unwrapping follows the usual processor rules:
555555
556556
- function processors receive unwrapped/renderable mapped values
557557
- component/template processors receive raw/reactive mapped values
558+
(single reactive values are unwrapped before reaching the component)
558559
559560
Use `*` to apply a processor to each item of a collection:
560561

src/js/select/ui/components/runtime.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ function applyNamedProcessor(
248248
) {
249249
if (processor.type === "component") {
250250
const component = processor.value;
251-
const value = current;
251+
const value = current?.isReactive === true ? unwrap(current) : current;
252252
if (value === undefined || value === null) return value;
253253
if (
254254
component?.isTemplate &&

tests/ui-processor-reactives.test.js

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,4 +455,106 @@ describe("ui processor reactive handling", () => {
455455
document.body.innerHTML = "";
456456
window.close?.();
457457
});
458+
459+
test("unwraps single reactive values before passing to component processors", async () => {
460+
const window = new Window({ url: "http://localhost:8000/repro" });
461+
setupGlobals(window);
462+
const { cell } = await import("../src/js/select/index.js");
463+
const { ui, format } = await import("../src/js/select/ui.js");
464+
465+
document.body.innerHTML = `
466+
<div id="app"></div>
467+
<template id="ProcessorSingleUnwrapRepro">
468+
<div out="symbol|ProbeComponent"></div>
469+
</template>
470+
`;
471+
472+
let seen = null;
473+
const ProbeComponent = ui(`<span out="flag"></span>`).does({
474+
flag: (_self, data) => {
475+
seen = data;
476+
return data?.isReactive === true ? "reactive" : "plain";
477+
},
478+
});
479+
registerFormat(format, "ProbeComponent", ProbeComponent);
480+
481+
const instance = ui("ProcessorSingleUnwrapRepro")
482+
.new()
483+
.set({ symbol: cell("AAPL") })
484+
.mount("#app");
485+
486+
expect(document.querySelector("#app span")?.textContent).toBe("plain");
487+
expect(seen).toBe("AAPL");
488+
489+
instance.unmount();
490+
document.body.innerHTML = "";
491+
window.close?.();
492+
});
493+
494+
test("preserves non-reactive values unchanged for component processors", async () => {
495+
const window = new Window({ url: "http://localhost:8000/repro" });
496+
setupGlobals(window);
497+
const { ui, format } = await import("../src/js/select/ui.js");
498+
499+
document.body.innerHTML = `
500+
<div id="app"></div>
501+
<template id="ProcessorPlainValueRepro">
502+
<div out="symbol|ProbeComponent"></div>
503+
</template>
504+
`;
505+
506+
let seen = null;
507+
const ProbeComponent = ui(`<span out="flag"></span>`).does({
508+
flag: (_self, data) => {
509+
seen = data;
510+
return typeof data === "string" ? `string:${data}` : "other";
511+
},
512+
});
513+
registerFormat(format, "ProbeComponent", ProbeComponent);
514+
515+
const instance = ui("ProcessorPlainValueRepro")
516+
.new()
517+
.set({ symbol: "AAPL" })
518+
.mount("#app");
519+
520+
expect(document.querySelector("#app span")?.textContent).toBe("string:AAPL");
521+
expect(seen).toBe("AAPL");
522+
523+
instance.unmount();
524+
document.body.innerHTML = "";
525+
window.close?.();
526+
});
527+
528+
test("preserves reactive values inside mapped objects for component processors", async () => {
529+
const window = new Window({ url: "http://localhost:8000/repro" });
530+
setupGlobals(window);
531+
const { cell } = await import("../src/js/select/index.js");
532+
const { ui, format } = await import("../src/js/select/ui.js");
533+
534+
document.body.innerHTML = `
535+
<div id="app"></div>
536+
<template id="ProcessorMappedPreserveRepro">
537+
<div out="a:b,c:d|ProbeComponent"></div>
538+
</template>
539+
`;
540+
541+
const ProbeComponent = ui(`<span out="flag"></span>`).does({
542+
flag: (_self, data) =>
543+
data.a?.isReactive === true && data.c?.isReactive === true
544+
? "reactive"
545+
: "plain",
546+
});
547+
registerFormat(format, "ProbeComponent", ProbeComponent);
548+
549+
const instance = ui("ProcessorMappedPreserveRepro")
550+
.new()
551+
.set({ b: cell("alpha"), d: cell("beta") })
552+
.mount("#app");
553+
554+
expect(document.querySelector("#app span")?.textContent).toBe("reactive");
555+
556+
instance.unmount();
557+
document.body.innerHTML = "";
558+
window.close?.();
559+
});
458560
});

tests/ui-reactive-props.tests.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe("select ui reactive bindings", () => {
5757

5858
Parent.new().set({ payload: reactive }).mount(document.body);
5959

60-
expect(seen).toBe(reactive);
60+
expect(seen).toEqual([{ id: "name" }]);
6161
});
6262

6363
it("keeps expanding non-component processors during render", () => {

0 commit comments

Comments
 (0)