Skip to content

Commit 11e280f

Browse files
committed
perf(web): debounce preview execution during edits
1 parent d184d0b commit 11e280f

5 files changed

Lines changed: 77 additions & 65 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ docker compose up --build
5252

5353
The local worker defaults to `http://localhost:8797`.
5454

55-
Current local qualification note: Lighthouse is green and the worker throughput gate passes, but the browser 100k-row p95 is still `2076.14 ms` versus the required `<= 1000 ms`.
55+
Current local qualification note: Lighthouse is green, browser p95 is `377.26 ms` for 100k rows, and the worker throughput gate passes. Remaining blockers are Miller parity completion, browser Miller-WASM evidence, hosted deployment, and final Section 21 closure.
5656

5757
## Release targets
5858

docs/qc/APPENDIX_B_REPORT.md

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ Verifier: Codex
99

1010
Counts:
1111
Total checks: 34
12-
Passed: 18
13-
Failed: 1
12+
Passed: 19
13+
Failed: 0
1414
Blocked: 15
1515

1616
Passed:
@@ -31,11 +31,9 @@ Passed:
3131
- 21.18 A1 sample outputs are recorded in this appendix for fixture-backed evidence.
3232
- 21.18 A2 worker escalation prompt covered by local unit tests and UI path.
3333
- 21.12 Worker native DuckDB benchmark clears the 100M-row threshold: `1057.87 ms` for `generate_series(1, 100000000)`.
34+
- 21.12 Browser 100k-row p95 now clears the target: `377.26 ms` in the production-preview run recorded at `docs/qc/benchmarks/browser-duckdb.json`.
3435
- Local Lighthouse preview scores clear the handoff gate: `performance=100`, `accessibility=100`, `best-practices=100`, and `seo=100`.
3536

36-
Failures:
37-
- 21.12 Browser 100k-row p95 still misses the target: `2076.14 ms` in the production-preview run recorded at `docs/qc/benchmarks/browser-duckdb.json`, versus the required `<= 1000 ms`.
38-
3937
Acceptance evidence:
4038
- Ecommerce CSV fixture aggregates to `books=42.5`, `electronics=129.99`, and `home=77.1` with one paid row per category.
4139
- Access-log JSONL fixture jq-style query returns request `r2` for path `/cart` with duration `983`.
@@ -47,7 +45,6 @@ Blocked:
4745
Browser DuckDB-WASM is now exercised by `pnpm --filter @csvshape/web smoke:duckdb`, which loads the ecommerce CSV sample, applies `filter -> stats1`, shows `Engine: DuckDB-WASM`, and captures `docs/qc/screenshots/duckdb-wasm-preview.png`.
4846
Worker-native DuckDB is packaged via `@duckdb/node-api`, reports `duckdbNative: true` on `/health`, and executes inline CSV/JSONL SQL plus Parquet export in local tests.
4947
Worker-native Miller now has a repeatable smoke run via `pnpm --filter @csvshape/worker smoke:mlr`, which returns `engine=mlr-native`, `rowCount=3`, and CSV output for the paid-order ecommerce subset in `docs/qc/benchmarks/native-mlr-smoke.json`.
50-
- 21.12 Browser auto mode now routes simple single-source chains to the faster TypeScript preview path, and the benchmark now runs against a production preview build instead of the dev server, but the browser p95 is still above target.
5148
- 21.13 Privacy evidence is now local-only rather than hosted.
5249
`pnpm audit:privacy` produces `docs/qc/benchmarks/browser-privacy.json`, which currently shows no worker calls, no cross-origin calls, and no browser storage writes during a standard browser-side sample transform.
5350
Worker `/health` and `/v1/run` responses continue to expose `artifactTtlSeconds=900` for retention handling, and worker tests now assert `Cache-Control: no-store` on those responses.

docs/qc/SECTION_21_STATUS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ It is not yet a qualified release record.
3434
Worker-native DuckDB execution is covered by local tests, and worker-native Miller now has a repeatable local smoke run via `pnpm --filter @csvshape/worker smoke:mlr` with artifact output in `docs/qc/benchmarks/native-mlr-smoke.json`.
3535
Browser Miller-WASM evidence and per-verb Miller parity evidence are still missing.
3636
- [ ] 21.12 Performance evidence for browser p95 and worker throughput.
37-
Browser 100k-row p95 currently measures `2076.14 ms` via the production-preview benchmark in `docs/qc/benchmarks/browser-duckdb.json`, which still misses the `<= 1000 ms` gate.
37+
Browser 100k-row p95 now measures `377.26 ms` via the production-preview benchmark in `docs/qc/benchmarks/browser-duckdb.json`, which clears the `<= 1000 ms` gate.
3838
Worker native DuckDB clears the 100M-row threshold at `1057.87 ms` via `docs/qc/benchmarks/worker-duckdb.json`.
3939
Local Lighthouse preview scores are `performance=100`, `accessibility=100`, `best-practices=100`, and `seo=100` via `docs/qc/benchmarks/lighthouse-summary.json`.
4040
- [ ] 21.13 Privacy proof for browser-first processing and worker retention TTL handling.

docs/qc/benchmarks/browser-duckdb.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
"baseUrl": "http://127.0.0.1:4173",
33
"edgeExecutable": "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
44
"iterationCount": 5,
5-
"p95Ms": 2076.14,
5+
"p95Ms": 377.26,
66
"rowCount": 100000,
77
"thresholdMs": 1000,
88
"timingsMs": [
9-
1355.49,
10-
1968.74,
11-
2076.14,
12-
1586.93,
13-
1425.28
9+
331.04,
10+
291.43,
11+
314.85,
12+
377.26,
13+
301.59
1414
]
1515
}

packages/web/src/App.tsx

Lines changed: 66 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
type PreviewTable,
1919
type VerbChain,
2020
} from '@csvshape/core';
21-
import { startTransition, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
21+
import { startTransition, useEffect, useMemo, useRef, useState } from 'react';
2222
import { Database, FileCog, Link2, Logs, Rows4, Upload } from 'lucide-react';
2323

2424
import { VERB_PALETTE } from './catalog';
@@ -171,6 +171,22 @@ function loadDuckDbBrowserModule() {
171171
return duckDbBrowserModulePromise;
172172
}
173173

174+
function useDebouncedValue<T>(value: T, delayMs: number): T {
175+
const [debouncedValue, setDebouncedValue] = useState(value);
176+
177+
useEffect(() => {
178+
const timeoutId = window.setTimeout(() => {
179+
setDebouncedValue(value);
180+
}, delayMs);
181+
182+
return () => {
183+
window.clearTimeout(timeoutId);
184+
};
185+
}, [delayMs, value]);
186+
187+
return debouncedValue;
188+
}
189+
174190
export function App() {
175191
const fileInputRef = useRef<HTMLInputElement | null>(null);
176192
const [sources, setSources] = useState<LoadedSource[]>([]);
@@ -197,9 +213,8 @@ export function App() {
197213
const [engineMessage, setEngineMessage] = useState<string | null>(null);
198214

199215
const selectedSource = sources.find((source) => source.id === selectedSourceId) ?? null;
200-
const deferredSource = useDeferredValue(selectedSource);
201-
const deferredChain = useDeferredValue(chain);
202-
const deferredJsonQuery = useDeferredValue(jsonQuery);
216+
const debouncedChain = useDebouncedValue(chain, 180);
217+
const debouncedJsonQuery = useDebouncedValue(jsonQuery, 180);
203218
const routeSeo = useMemo(() => getRouteSeo(window.location.pathname), []);
204219

205220
useEffect(() => {
@@ -377,26 +392,26 @@ export function App() {
377392
}
378393

379394
const execution = useMemo(() => {
380-
if (!deferredSource) {
395+
if (!selectedSource) {
381396
return null;
382397
}
383398

384-
let primarySource = deferredSource;
399+
let primarySource = selectedSource;
385400
const jsonWarnings: string[] = [];
386401

387-
if ((deferredSource.format === 'jsonl' || deferredSource.format === 'ndjson') && deferredJsonQuery.trim()) {
388-
const queried = applyJsonQuery(deferredSource.text, deferredJsonQuery);
402+
if ((selectedSource.format === 'jsonl' || selectedSource.format === 'ndjson') && debouncedJsonQuery.trim()) {
403+
const queried = applyJsonQuery(selectedSource.text, debouncedJsonQuery);
389404

390405
primarySource = {
391-
...deferredSource,
406+
...selectedSource,
392407
text: queried.rows.map((row) => JSON.stringify(row)).join('\n'),
393408
};
394409
jsonWarnings.push(...queried.warnings);
395410
}
396411

397412
const chainDefinition: VerbChain = {
398413
input: [{ format: primarySource.format, ref: primarySource.name }],
399-
verbs: deferredChain.map((step) => ({
414+
verbs: debouncedChain.map((step) => ({
400415
kind: step.kind,
401416
opts: step.opts,
402417
rawExpression: step.mode === 'raw' ? step.rawExpression : undefined,
@@ -407,23 +422,23 @@ export function App() {
407422
const result = executeVerbChain(
408423
chainDefinition,
409424
sources.map((source) => ({
410-
name: source.id === deferredSource.id ? primarySource.name : source.name,
411-
format: source.id === deferredSource.id ? primarySource.format : source.format,
412-
text: source.id === deferredSource.id ? primarySource.text : source.text,
413-
dialect: source.id === deferredSource.id ? null : source.inspection.dialect,
425+
name: source.id === selectedSource.id ? primarySource.name : source.name,
426+
format: source.id === selectedSource.id ? primarySource.format : source.format,
427+
text: source.id === selectedSource.id ? primarySource.text : source.text,
428+
dialect: source.id === selectedSource.id ? null : source.inspection.dialect,
414429
})),
415430
);
416431

417432
return {
418433
...result,
419434
warnings: [...result.warnings, ...jsonWarnings],
420435
};
421-
}, [deferredChain, deferredJsonQuery, deferredSource, outputFormat, sources]);
436+
}, [debouncedChain, debouncedJsonQuery, outputFormat, selectedSource, sources]);
422437

423438
useEffect(() => {
424439
let cancelled = false;
425440

426-
if (!deferredSource) {
441+
if (!selectedSource) {
427442
setDuckDbExecution(null);
428443
setExecutionEngine('typescript');
429444
setEngineMessage(null);
@@ -432,22 +447,22 @@ export function App() {
432447
};
433448
}
434449

435-
let primarySource = deferredSource;
450+
let primarySource = selectedSource;
436451
const jsonWarnings: string[] = [];
437452

438-
if ((deferredSource.format === 'jsonl' || deferredSource.format === 'ndjson') && deferredJsonQuery.trim()) {
439-
const queried = applyJsonQuery(deferredSource.text, deferredJsonQuery);
453+
if ((selectedSource.format === 'jsonl' || selectedSource.format === 'ndjson') && debouncedJsonQuery.trim()) {
454+
const queried = applyJsonQuery(selectedSource.text, debouncedJsonQuery);
440455

441456
primarySource = {
442-
...deferredSource,
457+
...selectedSource,
443458
text: queried.rows.map((row) => JSON.stringify(row)).join('\n'),
444459
};
445460
jsonWarnings.push(...queried.warnings);
446461
}
447462

448463
const duckDbChain: VerbChain = {
449464
input: [{ format: primarySource.format, ref: primarySource.name }],
450-
verbs: deferredChain.map((step) => ({
465+
verbs: debouncedChain.map((step) => ({
451466
kind: step.kind,
452467
opts: step.opts,
453468
rawExpression: step.mode === 'raw' ? step.rawExpression : undefined,
@@ -471,12 +486,12 @@ export function App() {
471486
duckDbChain,
472487
sources.map((source) => ({
473488
dialect:
474-
source.id === deferredSource.id
489+
source.id === selectedSource.id
475490
? primarySource.inspection.dialect
476491
: source.inspection.dialect,
477-
format: source.id === deferredSource.id ? primarySource.format : source.format,
478-
name: source.id === deferredSource.id ? primarySource.name : source.name,
479-
text: source.id === deferredSource.id ? primarySource.text : source.text,
492+
format: source.id === selectedSource.id ? primarySource.format : source.format,
493+
name: source.id === selectedSource.id ? primarySource.name : source.name,
494+
text: source.id === selectedSource.id ? primarySource.text : source.text,
480495
})),
481496
jsonWarnings,
482497
),
@@ -516,7 +531,7 @@ export function App() {
516531
return () => {
517532
cancelled = true;
518533
};
519-
}, [deferredChain, deferredJsonQuery, deferredSource, engineMode, outputFormat, sources]);
534+
}, [debouncedChain, debouncedJsonQuery, engineMode, outputFormat, selectedSource, sources]);
520535

521536
const reshaped = useMemo(() => {
522537
const baseExecution = duckDbExecution ?? execution;
@@ -600,13 +615,13 @@ export function App() {
600615
const exportContent = useMemo(() => serializeRows(exportRows, outputFormat), [exportRows, outputFormat]);
601616

602617
async function handleDownloadData() {
603-
if (outputFormat === 'parquet' && deferredSource) {
604-
let primarySource = deferredSource;
618+
if (outputFormat === 'parquet' && selectedSource) {
619+
let primarySource = selectedSource;
605620

606-
if ((deferredSource.format === 'jsonl' || deferredSource.format === 'ndjson') && deferredJsonQuery.trim()) {
607-
const queried = applyJsonQuery(deferredSource.text, deferredJsonQuery);
621+
if ((selectedSource.format === 'jsonl' || selectedSource.format === 'ndjson') && debouncedJsonQuery.trim()) {
622+
const queried = applyJsonQuery(selectedSource.text, debouncedJsonQuery);
608623
primarySource = {
609-
...deferredSource,
624+
...selectedSource,
610625
text: queried.rows.map((row) => JSON.stringify(row)).join('\n'),
611626
};
612627
}
@@ -615,18 +630,18 @@ export function App() {
615630
const parquetBuffer = await exportDuckDbParquet(
616631
{
617632
input: [{ format: primarySource.format, ref: primarySource.name }],
618-
verbs: deferredChain.map((step) => ({
633+
verbs: debouncedChain.map((step) => ({
619634
kind: step.kind,
620635
opts: step.opts,
621636
rawExpression: step.mode === 'raw' ? step.rawExpression : undefined,
622637
})),
623638
output: { format: outputFormat },
624639
},
625640
sources.map((source) => ({
626-
dialect: source.id === deferredSource.id ? primarySource.inspection.dialect : source.inspection.dialect,
627-
format: source.id === deferredSource.id ? primarySource.format : source.format,
628-
name: source.id === deferredSource.id ? primarySource.name : source.name,
629-
text: source.id === deferredSource.id ? primarySource.text : source.text,
641+
dialect: source.id === selectedSource.id ? primarySource.inspection.dialect : source.inspection.dialect,
642+
format: source.id === selectedSource.id ? primarySource.format : source.format,
643+
name: source.id === selectedSource.id ? primarySource.name : source.name,
644+
text: source.id === selectedSource.id ? primarySource.text : source.text,
630645
})),
631646
);
632647

@@ -800,8 +815,8 @@ export function App() {
800815
))}
801816
</div>
802817

803-
{deferredSource &&
804-
(deferredSource.format === 'jsonl' || deferredSource.format === 'ndjson') ? (
818+
{selectedSource &&
819+
(selectedSource.format === 'jsonl' || selectedSource.format === 'ndjson') ? (
805820
<div className="worker-box">
806821
<div className="panel-header compact">
807822
<Link2 size={16} />
@@ -1049,7 +1064,7 @@ export function App() {
10491064
<Database size={18} />
10501065
<h2>Result preview</h2>
10511066
</div>
1052-
{!deferredSource ? (
1067+
{!selectedSource ? (
10531068
<div className="preview-state">
10541069
{isLoading ? 'Loading source...' : 'Load a source to inspect preview data.'}
10551070
</div>
@@ -1058,15 +1073,15 @@ export function App() {
10581073
<div className="metadata-grid">
10591074
<div>
10601075
<span>Format</span>
1061-
<strong>{deferredSource.format.toUpperCase()}</strong>
1076+
<strong>{selectedSource.format.toUpperCase()}</strong>
10621077
</div>
10631078
<div>
10641079
<span>Encoding</span>
1065-
<strong>{deferredSource.inspection.encoding.encoding}</strong>
1080+
<strong>{selectedSource.inspection.encoding.encoding}</strong>
10661081
</div>
10671082
<div>
10681083
<span>Confidence</span>
1069-
<strong>{deferredSource.inspection.encoding.confidence.toFixed(2)}</strong>
1084+
<strong>{selectedSource.inspection.encoding.confidence.toFixed(2)}</strong>
10701085
</div>
10711086
<div>
10721087
<span>Columns</span>
@@ -1139,27 +1154,27 @@ export function App() {
11391154
</button>
11401155
</div>
11411156

1142-
{deferredSource.inspection.dialect ? (
1157+
{selectedSource.inspection.dialect ? (
11431158
<div className="dialect-controls">
11441159
<div className="dialect-pill">
11451160
Delimiter:{' '}
1146-
{deferredSource.inspection.dialect.delimiter === '\t'
1161+
{selectedSource.inspection.dialect.delimiter === '\t'
11471162
? 'TAB'
1148-
: deferredSource.inspection.dialect.delimiter}
1163+
: selectedSource.inspection.dialect.delimiter}
11491164
</div>
1150-
<div className="dialect-pill">Quote: {deferredSource.inspection.dialect.quote}</div>
1151-
<div className="dialect-pill">Escape: {deferredSource.inspection.dialect.escape}</div>
1165+
<div className="dialect-pill">Quote: {selectedSource.inspection.dialect.quote}</div>
1166+
<div className="dialect-pill">Escape: {selectedSource.inspection.dialect.escape}</div>
11521167
<div className="dialect-pill">
1153-
Line ending: {deferredSource.inspection.dialect.lineEnding}
1168+
Line ending: {selectedSource.inspection.dialect.lineEnding}
11541169
</div>
11551170
<label className="checkbox-row">
11561171
<input
11571172
type="checkbox"
1158-
checked={deferredSource.inspection.dialect.hasHeader}
1173+
checked={selectedSource.inspection.dialect.hasHeader}
11591174
onChange={(event) => {
11601175
setSources((current) =>
11611176
current.map((source) =>
1162-
source.id === deferredSource.id
1177+
source.id === selectedSource.id
11631178
? withHeaderOverride(source, event.target.checked)
11641179
: source,
11651180
),
@@ -1184,7 +1199,7 @@ export function App() {
11841199
</thead>
11851200
<tbody>
11861201
{previewRows.map((row, rowIndex) => (
1187-
<tr key={`${deferredSource.id}-${rowIndex}`}>
1202+
<tr key={`${selectedSource.id}-${rowIndex}`}>
11881203
{previewColumns.map((column) => (
11891204
<td key={`${rowIndex}-${column}`}>{row[column]}</td>
11901205
))}

0 commit comments

Comments
 (0)