Skip to content

Commit 6983c01

Browse files
serpentbladeclaude
andcommitted
chore(command-palette): re-vendor Combobox after memo + sync stale leaves
The combobox filteredOptions() memo changed the canonical @rozie-ui/combobox source, so command-palette's vendored copy (vendor-drift guard) and its compiled leaves must be regenerated. Re-vendored ×6. Incidentally corrects pre-existing leaf drift: the committed leaves were last emitted at 21bdd5a, before eefc7e8 ("wire up option-row flex layout") changed the CommandPalette source to wrap option label/group in <div class="rozie-command-palette- option">. The VR harness compiles from source (baseline already reblessed at c18c3e0), so the stale leaves went unnoticed; this re-emit aligns the shipped leaves with the current source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSYH6VBAJwa7nYy4AksuNH
1 parent c5c4ab1 commit 6983c01

14 files changed

Lines changed: 328 additions & 117 deletions

File tree

packages/ui/command-palette/packages/angular/src/Combobox.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -495,22 +495,48 @@ export class Combobox {
495495
virtualizerCleanup: any = null;
496496
gridScrollEl: any = null;
497497
remeasurePending = false;
498+
foCache = {
499+
optsRef: null,
500+
q: null,
501+
df: null,
502+
val: null,
503+
hasVal: false
504+
};
498505
filteredOptions = () => {
499506
const __options = this.options();
507+
const __query = this.query();
508+
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}): read ALL three reactive inputs
509+
// into locals at the TOP, BEFORE any cache-hit early return — read $data.query UNCONDITIONALLY
510+
// (even when disableFilter is true, mirroring windowing.rzts windowedRows void-touch discipline)
511+
// so the r-for accessor subscribes to them on every eval. An early return that skipped reading
512+
// them would leave the accessor un-subscribed → it would never re-run on a real input change →
513+
// stale/blank window.
500514
const opts = Array.isArray(__options) ? __options : [];
515+
const df = !!this.disableFilter();
516+
const q = String(__query == null ? '' : __query);
517+
// Reference-keyed cache HIT: same options reference, same query, same disableFilter → return the
518+
// SAME array reference (no re-map, no new wrappers). Pure ===, NOT a reactive subscription.
519+
if (this.foCache.hasVal && this.foCache.optsRef === opts && this.foCache.q === q && this.foCache.df === df) return this.foCache.val;
520+
// MISS → run the existing filter + map, then store keyed on (opts ref, query, disableFilter).
501521
let list = opts;
502-
if (!this.disableFilter()) {
503-
const q = this.query().toLowerCase();
504-
if (q) list = opts.filter((o: any) => String(this.labelOf(o)).toLowerCase().indexOf(q) !== -1);
522+
if (!df) {
523+
const ql = q.toLowerCase();
524+
if (ql) list = opts.filter((o: any) => String(this.labelOf(o)).toLowerCase().indexOf(ql) !== -1);
505525
}
506-
return list.map((o: any, i: any) => ({
526+
const val = list.map((o: any, i: any) => ({
507527
value: this.valueOf$local(o),
508528
label: this.labelOf(o),
509529
disabled: this.disabledOf(o),
510530
_i: i,
511531
id: this.valueOf$local(o),
512532
option: o
513533
}));
534+
this.foCache.optsRef = opts;
535+
this.foCache.q = q;
536+
this.foCache.df = df;
537+
this.foCache.val = val;
538+
this.foCache.hasVal = true;
539+
return val;
514540
};
515541
windowSource = () => this.filteredOptions();
516542
pinnedEditIndex = () => -1;

packages/ui/command-palette/packages/angular/src/CommandPalette.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,12 @@ function __rozieAttr(v: unknown): string | null {
6565
<ng-container *ngTemplateOutlet="(optionTpl ?? templates()?.['option']); context: { $implicit: { option: option, index: index, active: active, selected: selected, disabled: disabled }, option: option, index: index, active: active, selected: selected, disabled: disabled }" />
6666
} @else {
6767
68-
<span class="rozie-command-palette-option-label">{{ rozieDisplay(labelText(option)) }}</span>
69-
@if (groupText(option)) {
68+
<div class="rozie-command-palette-option">
69+
<span class="rozie-command-palette-option-label">{{ rozieDisplay(labelText(option)) }}</span>
70+
@if (groupText(option)) {
7071
<span class="rozie-command-palette-option-group">{{ rozieDisplay(groupText(option)) }}</span>
71-
}
72+
}</div>
73+
7274
}
7375
</ng-template><ng-template #empty let-query="query">
7476
@if ((emptyTpl ?? templates()?.['empty'])) {
@@ -141,18 +143,6 @@ function __rozieAttr(v: unknown): string | null {
141143
align-items: center;
142144
justify-content: space-between;
143145
gap: var(--rozie-command-palette-option-gap, 0.75rem);
144-
padding: var(--rozie-command-palette-option-padding, 0.5rem 0.625rem);
145-
border-radius: var(--rozie-command-palette-option-radius, 0.5rem);
146-
cursor: pointer;
147-
color: var(--rozie-command-palette-option-color, inherit);
148-
}
149-
.rozie-command-palette-option--active {
150-
background: var(--rozie-command-palette-option-active-bg, rgba(0, 102, 204, 0.12));
151-
color: var(--rozie-command-palette-option-active-color, inherit);
152-
}
153-
.rozie-command-palette-option--disabled {
154-
cursor: not-allowed;
155-
opacity: var(--rozie-command-palette-option-disabled-opacity, 0.45);
156146
}
157147
.rozie-command-palette-option-group {
158148
font-size: var(--rozie-command-palette-group-font-size, 0.75rem);

packages/ui/command-palette/packages/lit/src/Combobox.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -501,21 +501,47 @@ private __rozieWatchInitial_1 = true;
501501

502502
remeasurePending = false;
503503

504+
foCache = {
505+
optsRef: null,
506+
q: null,
507+
df: null,
508+
val: null,
509+
hasVal: false
510+
};
511+
504512
filteredOptions = () => {
513+
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}): read ALL three reactive inputs
514+
// into locals at the TOP, BEFORE any cache-hit early return — read $data.query UNCONDITIONALLY
515+
// (even when disableFilter is true, mirroring windowing.rzts windowedRows void-touch discipline)
516+
// so the r-for accessor subscribes to them on every eval. An early return that skipped reading
517+
// them would leave the accessor un-subscribed → it would never re-run on a real input change →
518+
// stale/blank window.
505519
const opts = Array.isArray(this.options) ? this.options : [];
520+
const df = !!this.disableFilter;
521+
const q = String(this._query.value == null ? '' : this._query.value);
522+
// Reference-keyed cache HIT: same options reference, same query, same disableFilter → return the
523+
// SAME array reference (no re-map, no new wrappers). Pure ===, NOT a reactive subscription.
524+
if (this.foCache.hasVal && this.foCache.optsRef === opts && this.foCache.q === q && this.foCache.df === df) return this.foCache.val;
525+
// MISS → run the existing filter + map, then store keyed on (opts ref, query, disableFilter).
506526
let list = opts;
507-
if (!this.disableFilter) {
508-
const q = this._query.value.toLowerCase();
509-
if (q) list = opts.filter((o: any) => String(this.labelOf(o)).toLowerCase().indexOf(q) !== -1);
527+
if (!df) {
528+
const ql = q.toLowerCase();
529+
if (ql) list = opts.filter((o: any) => String(this.labelOf(o)).toLowerCase().indexOf(ql) !== -1);
510530
}
511-
return list.map((o: any, i: any) => ({
531+
const val = list.map((o: any, i: any) => ({
512532
value: this.valueOf$local(o),
513533
label: this.labelOf(o),
514534
disabled: this.disabledOf(o),
515535
_i: i,
516536
id: this.valueOf$local(o),
517537
option: o
518538
}));
539+
this.foCache.optsRef = opts;
540+
this.foCache.q = q;
541+
this.foCache.df = df;
542+
this.foCache.val = val;
543+
this.foCache.hasVal = true;
544+
return val;
519545
};
520546

521547
windowSource = () => this.filteredOptions();

packages/ui/command-palette/packages/lit/src/CommandPalette.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -81,18 +81,6 @@ export default class CommandPalette extends SignalWatcher(LitElement) {
8181
align-items: center;
8282
justify-content: space-between;
8383
gap: var(--rozie-command-palette-option-gap, 0.75rem);
84-
padding: var(--rozie-command-palette-option-padding, 0.5rem 0.625rem);
85-
border-radius: var(--rozie-command-palette-option-radius, 0.5rem);
86-
cursor: pointer;
87-
color: var(--rozie-command-palette-option-color, inherit);
88-
}
89-
.rozie-command-palette-option--active[data-rozie-s-768cad96] {
90-
background: var(--rozie-command-palette-option-active-bg, rgba(0, 102, 204, 0.12));
91-
color: var(--rozie-command-palette-option-active-color, inherit);
92-
}
93-
.rozie-command-palette-option--disabled[data-rozie-s-768cad96] {
94-
cursor: not-allowed;
95-
opacity: var(--rozie-command-palette-option-disabled-opacity, 0.45);
9684
}
9785
.rozie-command-palette-option-group[data-rozie-s-768cad96] {
9886
font-size: var(--rozie-command-palette-group-font-size, 0.75rem);
@@ -244,8 +232,10 @@ ${this.open ? html`<div class="rozie-command-palette" @click=${($event: Event) =
244232
245233
<rozie-combobox .inline=${true} .disableFilter=${true} .closeOnSelect=${false} .options=${this.filteredItems()} .optionValue=${this.commandValue} .optionDisabled=${this.commandDisabled} .placeholder=${this.placeholder} .ariaLabel=${this.ariaLabel} .idBase=${this.idBase} .value=${this._activeValue.value} @value-change=${($event: CustomEvent) => { this._activeValue.value = $event.detail; }} @change=${($event: Event) => { this.onComboboxChange($event); }} @search=${(__rozieEv: CustomEvent) => { const $event = __rozieEv.detail; this.onComboboxSearch($event); }} data-rozie-s-768cad96 .option=${(scope: { option: unknown; index: unknown; active: unknown; selected: unknown; disabled: unknown }) => html`
246234
${this.option !== undefined ? this.option({option: scope.option, index: scope.index, active: scope.active, selected: scope.selected, disabled: scope.disabled}) : html`<slot name="option" data-rozie-params=${(() => { try { return JSON.stringify({option: scope.option, index: scope.index, active: scope.active, selected: scope.selected, disabled: scope.disabled}); } catch { return '{}'; } })()}>
247-
<span class="rozie-command-palette-option-label" data-rozie-s-768cad96>${rozieDisplay(this.labelText(scope.option))}</span>
248-
${this.groupText(scope.option) ? html`<span class="rozie-command-palette-option-group" data-rozie-s-768cad96>${rozieDisplay(this.groupText(scope.option))}</span>` : nothing}</slot>`}
235+
<div class="rozie-command-palette-option" data-rozie-s-768cad96>
236+
<span class="rozie-command-palette-option-label" data-rozie-s-768cad96>${rozieDisplay(this.labelText(scope.option))}</span>
237+
${this.groupText(scope.option) ? html`<span class="rozie-command-palette-option-group" data-rozie-s-768cad96>${rozieDisplay(this.groupText(scope.option))}</span>` : nothing}</div>
238+
</slot>`}
249239
`} .empty=${(scope: { query: unknown }) => html`
250240
${this.empty !== undefined ? this.empty({query: scope.query}) : html`<slot name="empty" data-rozie-params=${(() => { try { return JSON.stringify({query: scope.query}); } catch { return '{}'; } })()}>${this.emptyText}</slot>`}
251241
`} ${ref((el: Element | undefined) => el && adoptConsumerStyles(el, (this.constructor as { styles?: unknown }).styles))}></rozie-combobox>

packages/ui/command-palette/packages/react/src/Combobox.tsx

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
1+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
22
import type { ReactNode } from 'react';
33
import { clsx, parseInlineStyle, rozieAttr, rozieDisplay, useControllableState } from '@rozie/runtime-react';
44
import './Combobox.css';
@@ -311,21 +311,46 @@ const Combobox = forwardRef<ComboboxHandle, ComboboxProps>(function Combobox(_pr
311311
for (const it of items as any) if (it.index === r) return false;
312312
return true;
313313
}
314+
const foCache = useMemo(() => ({
315+
optsRef: null,
316+
q: null,
317+
df: null,
318+
val: null,
319+
hasVal: false
320+
}), []);
314321
function filteredOptions() {
322+
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}): read ALL three reactive inputs
323+
// into locals at the TOP, BEFORE any cache-hit early return — read $data.query UNCONDITIONALLY
324+
// (even when disableFilter is true, mirroring windowing.rzts windowedRows void-touch discipline)
325+
// so the r-for accessor subscribes to them on every eval. An early return that skipped reading
326+
// them would leave the accessor un-subscribed → it would never re-run on a real input change →
327+
// stale/blank window.
315328
const opts = Array.isArray(props.options) ? props.options : [];
329+
const df = !!props.disableFilter;
330+
const q = String(query == null ? '' : query);
331+
// Reference-keyed cache HIT: same options reference, same query, same disableFilter → return the
332+
// SAME array reference (no re-map, no new wrappers). Pure ===, NOT a reactive subscription.
333+
if (foCache.hasVal && foCache.optsRef === opts && foCache.q === q && foCache.df === df) return foCache.val;
334+
// MISS → run the existing filter + map, then store keyed on (opts ref, query, disableFilter).
316335
let list = opts;
317-
if (!props.disableFilter) {
318-
const q = query.toLowerCase();
319-
if (q) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(q) !== -1);
336+
if (!df) {
337+
const ql = q.toLowerCase();
338+
if (ql) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1);
320339
}
321-
return list.map((o: any, i: any) => ({
340+
const val = list.map((o: any, i: any) => ({
322341
value: valueOf(o),
323342
label: labelOf(o),
324343
disabled: disabledOf(o),
325344
_i: i,
326345
id: valueOf(o),
327346
option: o
328347
}));
348+
foCache.optsRef = opts;
349+
foCache.q = q;
350+
foCache.df = df;
351+
foCache.val = val;
352+
foCache.hasVal = true;
353+
return val;
329354
}
330355
function windowSource() {
331356
return filteredOptions();

packages/ui/command-palette/packages/react/src/CommandPalette.css

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -49,18 +49,6 @@
4949
align-items: center;
5050
justify-content: space-between;
5151
gap: var(--rozie-command-palette-option-gap, 0.75rem);
52-
padding: var(--rozie-command-palette-option-padding, 0.5rem 0.625rem);
53-
border-radius: var(--rozie-command-palette-option-radius, 0.5rem);
54-
cursor: pointer;
55-
color: var(--rozie-command-palette-option-color, inherit);
56-
}
57-
.rozie-command-palette-option--active[data-rozie-s-768cad96] {
58-
background: var(--rozie-command-palette-option-active-bg, rgba(0, 102, 204, 0.12));
59-
color: var(--rozie-command-palette-option-active-color, inherit);
60-
}
61-
.rozie-command-palette-option--disabled[data-rozie-s-768cad96] {
62-
cursor: not-allowed;
63-
opacity: var(--rozie-command-palette-option-disabled-opacity, 0.45);
6452
}
6553
.rozie-command-palette-option-group[data-rozie-s-768cad96] {
6654
font-size: var(--rozie-command-palette-group-font-size, 0.75rem);

packages/ui/command-palette/packages/react/src/CommandPalette.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,9 @@ const CommandPalette = forwardRef<CommandPaletteHandle, CommandPaletteProps>(fun
197197
<div ref={panel} className={"rozie-command-palette-panel"} role="dialog" aria-modal="true" aria-label={props.ariaLabel} onKeyDown={($event) => { onPanelKeydown($event); }} data-rozie-s-768cad96="">
198198

199199
<Combobox inline={true} disableFilter={true} closeOnSelect={false} options={filteredItems()} optionValue={commandValue} optionDisabled={commandDisabled} placeholder={props.placeholder} aria-label={props.ariaLabel} idBase={props.idBase} value={activeValue} onValueChange={setActiveValue} onChange={($event) => { onComboboxChange($event); }} onSearch={($event) => { onComboboxSearch($event); }} data-rozie-s-768cad96="" renderOption={({ option, index, active, selected, disabled }) => (<>
200-
{(props.renderOption ?? props.slots?.['option']) ? ((props.renderOption ?? props.slots?.['option']) as Function)({ option, index, active, selected, disabled }) : <><span className={"rozie-command-palette-option-label"} data-rozie-s-768cad96="">{rozieDisplay(labelText(option))}</span>{(groupText(option)) && <span className={"rozie-command-palette-option-group"} data-rozie-s-768cad96="">{rozieDisplay(groupText(option))}</span>}</>}
200+
{(props.renderOption ?? props.slots?.['option']) ? ((props.renderOption ?? props.slots?.['option']) as Function)({ option, index, active, selected, disabled }) : <div className={"rozie-command-palette-option"} data-rozie-s-768cad96="">
201+
<span className={"rozie-command-palette-option-label"} data-rozie-s-768cad96="">{rozieDisplay(labelText(option))}</span>
202+
{(groupText(option)) && <span className={"rozie-command-palette-option-group"} data-rozie-s-768cad96="">{rozieDisplay(groupText(option))}</span>}</div>}
201203
</>)} renderEmpty={({ query }) => (<>
202204
{(props.renderEmpty ?? props.slots?.['empty']) ? ((props.renderEmpty ?? props.slots?.['empty']) as Function)({ query }) : props.emptyText}
203205
</>)} />

packages/ui/command-palette/packages/solid/src/Combobox.tsx

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -493,22 +493,64 @@ export default function Combobox(_props: ComboboxProps): JSX.Element {
493493
// The filtered option list, each carrying its filtered-list index `_i`, a stable
494494
// windowing key `id`, and the RAW source option (`option`) so `@change` + the
495495
// `#option` slot expose the original object (CP reads `e.option.id` / `option.group`).
496-
// A plain function (called in the r-for AND handlers) — never $computed.
496+
//
497+
// REFERENCE-KEYED MEMO, NOT $computed — this is load-bearing for windowed perf. TanStack
498+
// virtual-core calls getItemKey(i)/getMeasurements O(count) times per pass, and windowSource()
499+
// (below) aliases this, so without a memo every scroll re-`.map()`s ALL options into fresh
500+
// wrapper objects — O(N²). On vue each wrapper read trips a reactive Proxy trap (valueOf/labelOf/
501+
// disabledOf), so a 60-ArrowDown batch over 1,000 options cost ~16s. It is deliberately NOT a
502+
// $computed: a $computed would re-SUBSCRIBE to the reactive `options` Proxy and re-run on
503+
// unrelated reactive churn (and on vue re-trip the Proxy traps); the whole point is to AVOID
504+
// re-mapping when only activeIndex changed. The cache key is pure VALUE/REFERENCE comparison
505+
// (no reactive subscription), so it adds zero reactivity churn — it collapses virtual-core's
506+
// O(count) re-maps to ONE map per real (options-ref / query / disableFilter) change.
507+
//
508+
// foCache is a member-mutated FRESH-OBJECT const (NOT a reassigned `let`): the React emitter
509+
// lowers `const X = {…}` that is member-mutated to `useMemo(() => ({…}), [])` (per-instance,
510+
// stable across renders — feedback_react_const_mutinstance_not_stabilized); on the 5 setup-once
511+
// targets the top-level const persists for the instance lifetime naturally. A reassigned
512+
// `let X = null` would NOT survive React renders (filteredOptions() is reached from the TEMPLATE,
513+
// not a hook-root → per-render reset trap), so it MUST be a fresh-object const.
514+
const foCache = {
515+
optsRef: null,
516+
q: null,
517+
df: null,
518+
val: null,
519+
hasVal: false
520+
};
497521
function filteredOptions() {
522+
// SUBSCRIBE FIRST (fine-grained Solid <For> / Svelte {#each}): read ALL three reactive inputs
523+
// into locals at the TOP, BEFORE any cache-hit early return — read $data.query UNCONDITIONALLY
524+
// (even when disableFilter is true, mirroring windowing.rzts windowedRows void-touch discipline)
525+
// so the r-for accessor subscribes to them on every eval. An early return that skipped reading
526+
// them would leave the accessor un-subscribed → it would never re-run on a real input change →
527+
// stale/blank window.
498528
const opts = Array.isArray(local.options) ? local.options : [];
529+
const df = !!local.disableFilter;
530+
const q = String(query() == null ? '' : query());
531+
// Reference-keyed cache HIT: same options reference, same query, same disableFilter → return the
532+
// SAME array reference (no re-map, no new wrappers). Pure ===, NOT a reactive subscription.
533+
if (foCache.hasVal && foCache.optsRef === opts && foCache.q === q && foCache.df === df) return foCache.val;
534+
// MISS → run the existing filter + map, then store keyed on (opts ref, query, disableFilter).
499535
let list = opts;
500-
if (!local.disableFilter) {
501-
const q = query().toLowerCase();
502-
if (q) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(q) !== -1);
536+
if (!df) {
537+
const ql = q.toLowerCase();
538+
if (ql) list = opts.filter((o: any) => String(labelOf(o)).toLowerCase().indexOf(ql) !== -1);
503539
}
504-
return list.map((o: any, i: any) => ({
540+
const val = list.map((o: any, i: any) => ({
505541
value: valueOf(o),
506542
label: labelOf(o),
507543
disabled: disabledOf(o),
508544
_i: i,
509545
id: valueOf(o),
510546
option: o
511547
}));
548+
foCache.optsRef = opts;
549+
foCache.q = q;
550+
foCache.df = df;
551+
foCache.val = val;
552+
foCache.hasVal = true;
553+
return val;
512554
}
513555

514556
// windowSource(): the windowing.rzts host-contract row source — the FILTERED option

0 commit comments

Comments
 (0)