Browse Source

fix(list-table-v2): eliminate resize flicker with queueMicrotask pattern

- Fix call site bug where renamed function handleResize wasn't called
- Use queueMicrotask to clear and set estimatedRowHeight in same microtask
- This minimizes the gap where el-table-v2 uses default row height
- Also update shouldUseProbeRow to check estimatedRowHeight prop
- Add cell-text CSS for proper overflow handling in mini table
- Document queueMicrotask pattern and build commands in spec guides

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dev
hechang27-sprt 3 months ago
parent
commit
179aafe7bb
  1. 47
      .trellis/spec/frontend/quality-guidelines.md
  2. 16
      .trellis/spec/guides/code-reuse-thinking-guide.md
  3. 2
      examples/view/base/table-v2.vue
  4. 50
      packages/base/data/list-table-v2.vue

47
.trellis/spec/frontend/quality-guidelines.md

@ -59,13 +59,26 @@ When making changes to `packages/` directory and testing with `examples/`:
const DEV_MODE_TS = "2026-03-26T03:50:00.000Z"; // Update timestamp to force reload const DEV_MODE_TS = "2026-03-26T03:50:00.000Z"; // Update timestamp to force reload
``` ```
### Build Commands
This project uses **bun** as package manager:
| Command | Purpose | When to Use |
|---------|---------|-------------|
| `bun run dev` | Start Vite dev server | Testing example project |
| `bun run build` | Build examples project | **DO NOT USE** - examples don't need build |
| `bun run build:lib` | Build library for external repos | When done with changes, to verify build |
| `bun run lint` | Lint code | Before commit |
**Common mistake**: Running `bun run build` which builds the examples project unnecessarily. The examples project is for development only and Vite handles hot reload automatically.
### Quick Test Cycle ### Quick Test Cycle
```bash ```bash
# 1. Make changes to packages/ # 1. Make changes to packages/
# 2. Dev server auto-reloads when accessing example pages # 2. Dev server auto-reloads when accessing example pages
# 3. Modify DEV_MODE_TS in packages/manage/router/index.vue if changes don't appear # 3. Modify DEV_MODE_TS in packages/manage/router/index.vue if changes don't appear
# 4. Run build when done to verify # 4. Run build:lib when done to verify
bun run build:lib bun run build:lib
``` ```
@ -120,6 +133,38 @@ const height = probeRowRef.value?.offsetHeight;
**Verification**: All rows should have identical heights and consistent spacing. **Verification**: All rows should have identical heights and consistent spacing.
### el-table-v2 resize with probe row (clear-then-set pattern)
When handling window/container resize with a probe row for dynamic row height:
**Problem**: Clearing `estimatedRowHeight` to trigger re-measure creates a visible flash (1-2 frames) where el-table-v2 uses default height.
**Root Cause**: The gap between clearing the old value and setting the new value is visible to el-table-v2.
**Solution**: Measure first (capturing new values), then use `queueMicrotask` to clear and set in the SAME microtask:
```typescript
// 1. Measure first while old values still set
const headerHeight = headerEl?.offsetHeight;
const rowHeight = firstRow?.offsetHeight;
const newHeader = headerHeight && headerHeight > 0 ? headerHeight : estimatedHeaderHeight.value;
const newRow = rowHeight && rowHeight > 0 ? rowHeight : estimatedRowHeight.value;
// 2. Clear then set in same microtask (before next paint)
estimatedRowHeight.value = undefined;
estimatedHeaderHeight.value = undefined;
queueMicrotask(() => {
estimatedRowHeight.value = newRow;
estimatedHeaderHeight.value = newHeader;
});
```
**Why queueMicrotask works**: It defers the set to the end of the current microtask queue, but BEFORE the next paint. So the sequence is:
- Microtask 1: `undefined` is set (triggers el-table-v2 re-measure)
- Microtask 2 (queued): New value is set
- Paint: Only one paint happens with the correct value
**Without queueMicrotask**: The clear and set happen in separate Vue reactivity updates, causing TWO paints (one with undefined, one with new value).
--- ---
## Code Review Checklist ## Code Review Checklist

16
.trellis/spec/guides/code-reuse-thinking-guide.md

@ -97,6 +97,22 @@ When you've made similar changes to multiple files:
--- ---
## Gotcha: Rename Without Propagation
**Problem**: When renaming a function, variable, or component, IDE refactoring tools update all references automatically. But if you manually rename (even with search-and-replace), you may miss some references.
**Symptom**: Code compiles/runs but behavior is broken because some code still references the old name.
**Example**: Renaming `debouncedMeasure` to `handleResize` but forgetting to update the call site in a ResizeObserver callback.
**Prevention checklist**:
- [ ] **Use IDE rename** (F2 in most IDEs) - it finds ALL references
- [ ] **If manual rename**: Search for the old name with grep BEFORE renaming to find all occurrences
- [ ] **If manual rename**: Search for the new name AFTER renaming to verify all references were updated
- [ ] **Test the feature** that uses the renamed entity
---
## Checklist Before Commit ## Checklist Before Commit
- [ ] Searched for existing similar code - [ ] Searched for existing similar code

2
examples/view/base/table-v2.vue

@ -39,7 +39,7 @@ const example = reactive({
const generateData = () => { const generateData = () => {
const rows = []; const rows = [];
const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds
const lorem = new LoremIpsum({ sentencesPerParagraph: { min: 1, max: 4 }, wordsPerSentence: { min: 5, max: 10 } }); const lorem = new LoremIpsum({ sentencesPerParagraph: { min: 2, max: 5 }, wordsPerSentence: { min: 5, max: 20 } });
for (let i = 1; i <= 100; i++) { for (let i = 1; i <= 100; i++) {
rows.push({ rows.push({
id: i, id: i,

50
packages/base/data/list-table-v2.vue

@ -6,7 +6,12 @@
<div v-if="miniTableData.length > 0" class="mini-table-inner"> <div v-if="miniTableData.length > 0" class="mini-table-inner">
<!-- Mini header row --> <!-- Mini header row -->
<div ref="miniHeaderRef" class="mini-row mini-header"> <div ref="miniHeaderRef" class="mini-row mini-header">
<div v-for="item in prop.columns || []" :key="item.key" class="mini-cell mini-header-cell" :style="getMiniCellStyle(item)"> <div
v-for="item in prop.columns || []"
:key="item.key"
class="mini-cell mini-header-cell"
:style="getMiniCellStyle(item)"
>
<span class="mini-header-content">{ item.name || (item.i18n ? t(item.i18n) : item.key) }</span> <span class="mini-header-content">{ item.name || (item.i18n ? t(item.i18n) : item.key) }</span>
</div> </div>
</div> </div>
@ -191,7 +196,7 @@ const getProbeCellText = (rowData: any, item: TableColumn): string => {
// Whether to use probe row for dynamic height measurement // Whether to use probe row for dynamic height measurement
// Only use probe row when rowHeight is NOT explicitly specified // Only use probe row when rowHeight is NOT explicitly specified
const shouldUseProbeRow = computed(() => { const shouldUseProbeRow = computed(() => {
return prop.rowHeight === undefined; return prop.rowHeight === undefined && prop.estimatedRowHeight === undefined;
}); });
// The estimated row height to pass to el-table-v2 // The estimated row height to pass to el-table-v2
@ -243,36 +248,37 @@ watch(
// Setup ResizeObserver on mount (not window resize - that causes flickering) // Setup ResizeObserver on mount (not window resize - that causes flickering)
onMounted(() => { onMounted(() => {
// Create debounced resize handler for mini table width changes // Resize handler - measure first, then clear and set in same microtask
const debouncedMeasure = lodash.debounce(async () => { const handleResize = lodash.debounce(async () => {
if (!shouldUseProbeRow.value || miniTableData.value.length === 0) { if (!shouldUseProbeRow.value || miniTableData.value.length === 0) {
return; return;
} }
// Clear previous measurements so el-table-v2 recalculates
estimatedRowHeight.value = undefined;
estimatedHeaderHeight.value = undefined;
await nextTick(); await nextTick();
// Wait TWO frames for layout to settle
await new Promise((resolve) => requestAnimationFrame(resolve)); await new Promise((resolve) => requestAnimationFrame(resolve));
await new Promise((resolve) => requestAnimationFrame(resolve)); await new Promise((resolve) => requestAnimationFrame(resolve));
// Measure header height
// Measure first while old values still set
const headerEl = miniTableRef.value?.querySelector(".mini-header"); const headerEl = miniTableRef.value?.querySelector(".mini-header");
const headerHeight = headerEl?.offsetHeight; const headerHeight = headerEl?.offsetHeight;
if (headerHeight && headerHeight > 0) {
estimatedHeaderHeight.value = headerHeight;
}
// Measure first row height
const firstRow = miniTableRef.value?.querySelector(".mini-row:not(.mini-header)"); const firstRow = miniTableRef.value?.querySelector(".mini-row:not(.mini-header)");
const rowHeight = firstRow?.offsetHeight; const rowHeight = firstRow?.offsetHeight;
if (rowHeight && rowHeight > 0) {
estimatedRowHeight.value = rowHeight; const newHeader = headerHeight && headerHeight > 0 ? headerHeight : estimatedHeaderHeight.value;
} const newRow = rowHeight && rowHeight > 0 ? rowHeight : estimatedRowHeight.value;
// Clear then set in same microtask to minimize gap
estimatedRowHeight.value = undefined;
estimatedHeaderHeight.value = undefined;
queueMicrotask(() => {
estimatedRowHeight.value = newRow;
estimatedHeaderHeight.value = newHeader;
});
}, 50); }, 50);
// Use ResizeObserver on .my-table to detect width changes // Use ResizeObserver on .my-table to detect width changes
// This is better than window resize because it only fires when OUR container changes // This is better than window resize because it only fires when OUR container changes
miniTableResizeObserver = new ResizeObserver(() => { miniTableResizeObserver = new ResizeObserver(() => {
debouncedMeasure(); handleResize();
}); });
if (myTableRef.value) { if (myTableRef.value) {
@ -472,12 +478,12 @@ const renderCellContent = (item: TableColumn, value: any, row: any, slots: Retur
// Handle dict display // Handle dict display
if (item.dict) { if (item.dict) {
return <span>{formatterByDist(item.dict, value)}</span>; return <span class="cell-text">{formatterByDist(item.dict, value)}</span>;
} }
// Handle formatting // Handle formatting
const formatted = formatCellValue(value, item, row); const formatted = formatCellValue(value, item, row);
return <span>{formatted}</span>; return <span class="cell-text">{formatted}</span>;
}; };
// Get mini cell style - mirrors the real table's column width/flex distribution // Get mini cell style - mirrors the real table's column width/flex distribution
@ -609,6 +615,12 @@ const getMiniCellStyle = (item: TableColumn): Record<string, string> => {
background: v-bind("state.style.tableChildBg") !important; background: v-bind("state.style.tableChildBg") !important;
} }
.my-table :deep(.cell-text) {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.my-pagination { .my-pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;

Loading…
Cancel
Save