f04876658f4ae0ec7c633895c21f153975a2a506
max
  Tue Sep 15 05:48:42 2026 -0700
faceted composite: default sort column, restored drag order, and two smaller things, refs #38353

defaultSortCol was still 1, which the drag-handle column took over when manual row ordering
went in.  A composite with no defaultSortField opened sorted by a hidden column, with no sort
arrow on any visible heading.

Fixing that alone exposed the next one: coming back on the Active tab called setFilterMode(),
which renumbers the order field from whatever is on screen, and that threw away the hand-
dragged order just read back from localStorage.  It was invisible only because the current
order happened to be the drag order while the default sort column was the drag column.  The
restore now keeps the saved order; a click still renumbers, which is what a click means.

Saved state had no eviction, so once the quota filled, the QuotaExceededError was swallowed
and from then on no faceted composite on the origin could save anything again.  Each entry
now carries a timestamp and a full store drops the least recently written entry belonging to
another composite and tries again.

Narrowing a facet turned the track container back on even with nothing selected, which brings
back an empty image.

diff --git src/hg/js/facetedComposite.js src/hg/js/facetedComposite.js
index 3ccac1a4448..587ca5a2bea 100644
--- src/hg/js/facetedComposite.js
+++ src/hg/js/facetedComposite.js
@@ -211,36 +211,83 @@
             return (state && typeof state === "object") ? state : {};
         } catch (e) {
             return {};
         }
     }
 
     // A page length the user picked wins.  Otherwise paginating a table that
     // would nearly fit anyway just hides rows behind a menu, so show everything
     // up to the first menu step past 25.  -1 is what DataTables reads as "all".
     function savedPageLength(saved, rowCount) {
         if (typeof saved === "number" && saved !== 0)
             return saved;
         return rowCount < 50 ? -1 : 25;
     }
 
+    // Drop the least recently written saved state belonging to some *other*
+    // faceted composite, to make room.  Returns false when there is nothing
+    // left to drop, which is the caller's signal to give up.
+    function evictOtherUiState() {
+        try {
+            let oldestKey = null, oldestTime = Infinity;
+            for (let i = 0; i < localStorage.length; i++) {
+                const key = localStorage.key(i);
+                if (!key || !key.startsWith("facetedComposite.") || key === uiStateKey)
+                    continue;
+                let saved = 0;
+                try {
+                    // An entry we cannot read is the first one to go.
+                    saved = (JSON.parse(localStorage.getItem(key)) || {}).saved || 0;
+                } catch (e) {
+                    saved = 0;
+                }
+                if (saved < oldestTime) {
+                    oldestTime = saved;
+                    oldestKey = key;
+                }
+            }
+            if (oldestKey === null)
+                return false;
+            localStorage.removeItem(oldestKey);
+            return true;
+        } catch (e) {
+            return false;
+        }
+    }
+
     function saveUiState(patch) {
+        // The timestamp is what makes eviction above "least recently written"
+        // rather than arbitrary.
+        const write = () => localStorage.setItem(
+            uiStateKey,
+            JSON.stringify(Object.assign(loadUiState(), patch, {saved: Date.now()})));
         try {
-            localStorage.setItem(uiStateKey,
-                                 JSON.stringify(Object.assign(loadUiState(), patch)));
+            write();
         } catch (e) {
-            /* private window, or the quota is full; the page works without it */
+            // Out of quota.  A hand-dragged order is one id per row, so a few
+            // large composites can fill the store between them; without the
+            // retry below the first one to hit the limit would leave every
+            // faceted composite on this origin silently unable to save again.
+            while (evictOtherUiState()) {
+                try {
+                    write();
+                    return;
+                } catch (e2) {
+                    /* still no room; drop another and try again */
+                }
+            }
+            /* private window, or nothing left to evict; the page works without it */
         }
     }
 
     function generateHTML() {
         const container = document.createElement("div");
         container.id = "myTag";
         container.innerHTML = `
         <div id="dataTypeSelector"></div>
         <div id="container">
             <div id="filters"></div>
             <div id="tableColumn">
                 <div id="sortNote" class="smallText"></div>
                 <table id="theMetaDataTable">
                     <thead></thead>
                     <tfoot></tfoot>
@@ -468,31 +515,33 @@
         // checkboxes and the drag handle.
         const DATA_COL_OFFSET = 2;
 
         // Map a metadata field name to its DataTables column index, matching
         // case-insensitively and ignoring leading underscores.  Returns -1 when
         // the name isn't one of the metadata columns.
         const colIdxForName = name => {
             const target = name.replace(/^_+/, "").toLowerCase();
             const idx = colNames.findIndex(
                 c => c.replace(/^_+/, "").toLowerCase() === target);
             return idx >= 0 ? idx + DATA_COL_OFFSET : -1;
         };
 
         // Determine which column to sort by: use defaultSortField if it matches
         // a metadata column, otherwise fall back to the first data column.
-        let defaultSortCol = 1;  // column 0 is checkboxes, 1 is first data col
+        // column 0 is the checkboxes and column 1 the drag handle, so the first
+        // data column is at DATA_COL_OFFSET
+        let defaultSortCol = DATA_COL_OFFSET;
         if (embeddedData.defaultSortField) {
             const idx = colIdxForName(embeddedData.defaultSortField);
             if (idx > 0)
                 defaultSortCol = idx;
         }
 
         // A sort the user established on an earlier visit wins over
         // defaultSortField.  facetSortOrder mirrors the classic composite
         // '<track>.sortOrder' cart value: 'field=+ field2=-', in sort precedence
         // order.  Fields no longer present in the metadata are dropped, so a
         // changed metadata file degrades to a partial (or default) sort rather
         // than an error.
         let initialOrder = [[defaultSortCol, "asc"]];
         if (embeddedData.facetSortOrder) {
             const savedOrder = embeddedData.facetSortOrder.trim().split(/\s+/)
@@ -693,47 +742,53 @@
             allTab.textContent = `All ${itemLabelCap} (${totalCount})`;
             // "All" means every row in the table, drawn or not, so it keeps the
             // plain noun; the other tab is the subset that reaches the image.
             selectedTab.textContent =
                 `${itemLabelCap} shown in the browser (${selCount})`;
             const showSelected = toggleCheckbox.checked;
             allTab.classList.toggle("active", !showSelected);
             selectedTab.classList.toggle("active", showSelected);
         }
         updateSelectedText();
 
         table.on("length", (e, settings, len) => saveUiState({pageLength: len}));
 
         // Clicking a tab switches the selection filter and redraws. "Selected"
         // is always clickable; with nothing selected it just shows an empty list.
-        function setFilterMode(showSelected) {
+        function setFilterMode(showSelected, keepSavedOrder) {
             toggleCheckbox.checked = showSelected;
             // Rows can only be dragged on the Active tab, and dragging only
             // means something while the table is sorted by the drag column, so
             // entering the tab renumbers that column from whatever order is on
             // screen and sorts by it.  Leaving restores nothing: the column
             // sort the user had is still in the header, one click away.
+            // keepSavedOrder skips the renumbering, for the one caller that is
+            // not a click: restoring this tab on page load, where the order
+            // field already holds the hand-dragged order read back from
+            // localStorage and renumbering would throw it away.
             table.column(reorderColIdx).visible(showSelected, false);
             syncReorderSearchCell(showSelected);
             table.rowReorder[showSelected ? "enable" : "disable"]();
             if (showSelected) {
+                if (!keepSavedOrder) {
                     let n = 0;
                     table.rows({order: "current", search: "none"}).every(function () {
                         const d = this.data();
                         d[ORDER_FIELD] = n++;
                         this.data(d);
                     });
+                }
                 table.order([reorderColIdx, "asc"]);
             }
             table.draw();
             updateSelectedText();
             saveUiState({tab: showSelected ? "active" : "all"});
         }
         allTab.addEventListener("click", () => setFilterMode(false));
         selectedTab.addEventListener("click", () => {
             setFilterMode(true);
             // Asking to see just the samples that reach the image is a request
             // to see the image.  Hooked on the click rather than inside
             // setFilterMode, which also runs when the tab is restored on page
             // load, where nothing the user did should move the dropdown.
             showTracksNow();
         });
@@ -899,31 +954,31 @@
         });
 
         const scrollBox = document.createElement("div");
         scrollBox.className = "table-xscroll";
         tableEl.parentNode.insertBefore(scrollBox, tableEl);
         scrollBox.appendChild(tableEl);
 
         // Come back on whichever tab was showing, through setFilterMode so the
         // drag column and the row numbering are set up exactly as a click would
         // leave them.  This has to run last: the search plug-in that hides
         // unselected rows is registered further down this function, and a draw
         // before that point leaves the tab highlighted while the table still
         // shows every row.  Only restored when something is actually selected,
         // since this tab on an empty selection is a blank table.
         if (savedState.tab === "active" && table.rows({selected: true}).count())
-            setFilterMode(true);
+            setFilterMode(true, true);
 
         return table;
     }  // end initTable
 
 
     // Map of colName -> Map of lowercaseId -> spanElement, for dynamic counts
     const countSpans = new Map();
     // Filter state for cross-facet count computation
     const checkboxFilters = new Map();  // colName -> Set<string> (lowercase ids)
     const textFilters = new Map();      // colName -> lowercase string
 
     function updateFacetCounts(metadata) {
         // For each facet, count values among rows that pass all OTHER filters
         // (excluding this facet's own checkbox filter). This way, unchecked
         // values show how many rows would be added if you checked them.
@@ -1058,30 +1113,34 @@
                 const checkedIds = new Set(
                     cboxes.filter(c => c.checked)
                           .map(c => c.dataset.valueId.toLowerCase())
                 );
                 if (checkedIds.size)
                     checkboxFilters.set(key, checkedIds);
                 else
                     checkboxFilters.delete(key);
                 const facets = Object.assign({}, loadUiState().facets);
                 if (checkedIds.size)
                     facets[key] = [...checkedIds];
                 else
                     delete facets[key];
                 saveUiState({facets: facets});
                 if (narrowing) {
+                    // Only worth turning the container back on when there is
+                    // something for it to draw.  With nothing selected the
+                    // track would come back as an empty image.
+                    if (table.rows({selected: true}).count())
                         showTracks();
                     // Narrowing by a facet is about finding samples in the full
                     // list, so a facet applied while only the selected rows are
                     // showing would filter a handful of rows the user had
                     // already picked.  Drop back to all of them.
                     showAllRows();
                 }
                 table.draw();
                 updateActiveFilters();
             };
             if (!countSpans.has(key)) countSpans.set(key, new Map());
             const colSpans = countSpans.get(key);
             topToShow.forEach(({id, display, count}) => {
                 const label = document.createElement("label");
                 const checkbox = document.createElement("input");