perf(search): load the results page on demand; lighter preview; lazy chunk budgets

Measured on the static export after merging main (a724fc1), against main:
total JS was 1067.0 KB (main 1005.4), over the 1050 KB cap.

- /app/search loads SearchPage through import(), so the results page is no
  longer in any route's first load (/app/search first load 334.5 KB, main
  335.2).
- The record preview renders values itself (file thumbnails, chips, Yes/No,
  dates, links, text) instead of through FieldValue, whose widget imports
  pulled two re-bundled chunks (+17.1 KB) into the preview. Secrets
  (REVEAL), widgets, code, HTML and binary fields are not previewed.
- The record search response schemas move from the shared schemas module to
  record-search.ts, their only user.
- perf-budget.json budgets the four on-demand search chunks under
  lazyChunks with their measured sizes (search page 13.6, quick search panel
  9.7, palette record groups 7.8, record preview 7.3 KB gzip; caps about 8%
  above), so they leave the total but keep their own caps and must stay out
  of every first load.
- A few one-off utility classes are swapped for ones main already ships.

Result: total JS 1012.7 KB (main +7.3), CSS 21.9 KB (main +0.4), every route
first load within 0.5 KB of main; no cap raised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Opus 5.5 committed 2026-10-12 14:10:55 -05:00
1 parent 2ed5239642
commit ddc66c6b63
8 files changed
+234 -86

No files matched your search

+20
View File
@@ -36,6 +36,26 @@
"$comment": "sql-formatter with the postgresql, mysql, sqlite and sql dialects only (formatDialect), loaded when a workbench cell's Format button is first used: import() in SqlCell. Measured 60.2 KB gzip at introduction; budget about 8% above.",
"marker": "logicalOperatorNewline",
"gzipKB": 65
},
"searchPage": {
"$comment": "The search results page (/app/search: filters, sort, result cards, split preview pane, side sheet), loaded on demand: import() in app/search/page.tsx. Measured 13.4 KB gzip at introduction (QRun-IO/qqq-frontend-next#59); budget about 8% above.",
"marker": "search-result-card",
"gzipKB": 14.5
},
"quickSearchPanel": {
"$comment": "The quick search results panel of the header box and the '/' dialog (groups, highlights, completions, failure and fallback notices), loaded when the panel first opens: import() in GlobalSearch and SearchDialog. Measured 9.4 KB gzip at introduction (#59); budget about 8% above.",
"marker": "search-results-status",
"gzipKB": 10.2
},
"commandRecordResults": {
"$comment": "The command palette's record groups, loaded when the palette searches records: import() in CommandMenu. Measured 7.5 KB gzip at introduction (#59); budget about 8% above.",
"marker": "command-menu-searching",
"gzipKB": 8.1
},
"recordPreview": {
"$comment": "The search result record preview (first-tier fields, file thumbnails, quick actions), loaded when a preview first shows: import() in SearchPage. It renders values itself rather than through FieldValue, which would pull the widget tree in (+17 KB measured). Measured 7.3 KB gzip at introduction (#59); budget about 8% above.",
"marker": "search-record-preview",
"gzipKB": 7.9
}
}
}
+12 -1
View File
@@ -23,8 +23,19 @@
'use client'
import React from 'react'
import dynamic from 'next/dynamic'
import { Loader2 } from 'lucide-react'
import { SearchPage } from '@/components/search/SearchPage'
/** The results page loads on demand, so its code stays out of every route's first load. */
const SearchPage = dynamic(() => import('@/components/search/SearchPage').then((module) => module.SearchPage), {
ssr: false,
loading: () => (
<p className="flex items-center gap-2 p-6 text-sm text-muted-foreground" role="status" data-qqq-id="search-page-loading">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading search…
</p>
),
})
/**
* Renders the search page at `/app/search`.
@@ -0,0 +1,47 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect } from 'vitest'
import type { QFieldMetaData, QTableMetaData } from '@/types'
import { previewFields } from './RecordPreview'
/** A field for the fixture. */
const field = (name: string, extra: Partial<QFieldMetaData> = {}) => ({ name, label: name, type: 'STRING', adornments: [], ...extra }) as unknown as QFieldMetaData
describe('previewFields', () => {
it('shows the first-tier fields but never the key, heavy fields, secrets, widgets, code, HTML or binary data', () => {
const fields = {
id: field('id', { type: 'INTEGER' }),
name: field('name'),
apiKey: field('apiKey', { adornments: [{ type: 'REVEAL' }] } as Partial<QFieldMetaData>),
chart: field('chart', { adornments: [{ type: 'WIDGET' }] } as Partial<QFieldMetaData>),
script: field('script', { adornments: [{ type: 'CODE_EDITOR' }] } as Partial<QFieldMetaData>),
body: field('body', { type: 'HTML' } as Partial<QFieldMetaData>),
blob: field('blob', { type: 'BLOB' } as Partial<QFieldMetaData>),
notes: field('notes', { isHeavy: true }),
status: field('status'),
other: field('other'),
}
const table = {
name: 'thing',
primaryKeyField: 'id',
fields,
sections: [{ name: 'identity', tier: 'T1', fieldNames: ['id', 'name', 'apiKey', 'chart', 'script', 'body', 'blob', 'notes', 'status'] }, { name: 'more', tier: 'T2', fieldNames: ['other'] }],
} as unknown as QTableMetaData
expect(previewFields(table).map((f) => f.name)).toEqual(['name', 'status'])
})
})
+77 -6
View File
@@ -28,14 +28,20 @@ import Link from 'next/link'
import { ExternalLink, Link2, Loader2, Pencil, ArrowRight } from 'lucide-react'
import { toast } from 'sonner'
import type { QFieldMetaData, QIcon, QTableMetaData } from '@/types'
import type { QFieldMetaData, QIcon, QRecord, QTableMetaData } from '@/types'
import type { RecordSearchResult } from '@/lib/api/record-search'
import { useRecord } from '@/lib/hooks/use-record'
import { useTableMetaData } from '@/lib/hooks/use-metadata'
import { canEditRecords } from '@/lib/auth/permissions'
import { chipStyle, CHIP_COLOR_CLASSES, findAdornment } from '@/lib/utils/adornment-utils'
import { cn } from '@/lib/utils/cn'
import { filePreview } from '@/lib/utils/file-preview'
import { isEmail, isHttpUrl } from '@/lib/utils/string-utils'
import { storedRecordVariantJson } from '@/lib/utils/table-variant'
import { recordSearchResultPath } from '@/lib/utils/record-search'
import { MetadataIcon } from '@/components/layout/MetadataIcon'
import { FieldValue } from '@/components/records/FieldValue'
import { DateTimeText } from '@/components/ui/DateTimeText'
import { FileThumbnail } from '@/components/preview/FileThumbnail'
import { HighlightedText } from './HighlightedText'
/** Most fields shown in a preview. */
@@ -43,7 +49,8 @@ const MAX_PREVIEW_FIELDS = 10
/**
* The fields a preview shows: the visible fields of the table's first-tier sections (as the
* record view's T1 cards), else all visible fields; never heavy fields or the primary key.
* record view's T1 cards), else all visible fields; never heavy fields, the primary key, secrets,
* widgets, code, HTML or binary data.
*
* @param table - Full table metadata.
* @returns Up to {@link MAX_PREVIEW_FIELDS} fields.
@@ -54,10 +61,74 @@ export function previewFields(table: QTableMetaData): QFieldMetaData[] {
const names = t1.length > 0 ? t1.flatMap((section) => section.fieldNames ?? []) : Object.keys(fields)
return [...new Set(names)]
.map((name) => fields[name])
.filter((field): field is QFieldMetaData => Boolean(field) && !field.isHidden && !field.isHeavy && field.name !== table.primaryKeyField)
.filter((field): field is QFieldMetaData => Boolean(field) && !field.isHidden && !field.isHeavy && field.name !== table.primaryKeyField && previewable(field))
.slice(0, MAX_PREVIEW_FIELDS)
}
/** Adornments whose fields a glance does not show: secrets, embedded widgets, code and markup. */
const NOT_PREVIEWED = ['REVEAL', 'WIDGET', 'CODE_EDITOR', 'RENDER_HTML'] as const
/**
* Whether a preview shows a field: never secrets (REVEAL), widgets, code, HTML or binary data.
*
* @param field - The field.
* @returns True when the preview shows it.
*/
function previewable(field: QFieldMetaData): boolean {
if (field.type === 'BLOB' || field.type === 'HTML') return false
return !NOT_PREVIEWED.some((type) => findAdornment(field, type))
}
/** Link classes (taller touch targets on coarse pointers). */
const LINK_CLASS = 'text-primary hover:text-primary/80 hover:underline pointer-coarse:inline-flex pointer-coarse:min-h-11 pointer-coarse:items-center'
/**
* A field's value at a glance: file thumbnails, chips, Yes/No, dates, email and web links,
* else the display value. Lighter than the record view's FieldValue (no widgets), so the
* preview loads quickly.
*
* @param props - Component properties.
* @param props.field - The field.
* @param props.record - The record.
* @param props.table - Its table.
* @returns The value.
*/
function PreviewValue({ field, record, table }: { field: QFieldMetaData; record: QRecord; table: QTableMetaData }) {
const rawValue = record.values[field.name]
const displayValue = record.displayValues?.[field.name]
const value = displayValue ?? rawValue
const dataQqqId = `search-preview-value-${field.name}`
const file = filePreview(field, record, storedRecordVariantJson(table))
if (file) return <FileThumbnail field={field} record={record} item={file} size={96} showName dataQqqId={dataQqqId} />
if (value === null || value === undefined || value === '') return <span className="text-muted-foreground" data-qqq-id={dataQqqId}>—</span>
if (findAdornment(field, 'CHIP')) {
const { color, icon } = chipStyle(field, rawValue)
return (
<span className={cn('inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold', CHIP_COLOR_CLASSES[color])} data-qqq-id={dataQqqId}>
{icon && <MetadataIcon iconName={icon} className="h-3.5 w-3.5" />}
{String(value)}
</span>
)
}
if (field.type === 'BOOLEAN') {
const yes = rawValue === true || rawValue === 'true' || rawValue === 1
return <span data-qqq-id={dataQqqId}>{yes ? 'Yes' : 'No'}</span>
}
if (field.type === 'DATE' || field.type === 'DATE_TIME') {
return <DateTimeText type={field.type} value={rawValue} displayValue={displayValue} dataQqqId={dataQqqId} />
}
const text = String(value)
if (field.type === 'STRING' && isHttpUrl(text)) {
return <a href={text} target="_blank" rel="noopener noreferrer" className={cn('break-all', LINK_CLASS)} data-qqq-id={dataQqqId}>{text}<span className="sr-only"> (opens in a new tab)</span></a>
}
if (field.type === 'STRING' && isEmail(text)) {
return <a href={`mailto:${text}`} className={cn('break-all', LINK_CLASS)} data-qqq-id={dataQqqId}>{text}</a>
}
return <span className={cn('whitespace-pre-wrap', field.type === 'TEXT' && 'line-clamp-3')} data-qqq-id={dataQqqId}>{text}</span>
}
/** Props for {@link RecordPreview}. */
export interface RecordPreviewProps {
/** The search result to preview. */
@@ -167,12 +238,12 @@ export function RecordPreview({ result, tableLabel, icon, term, headingId }: Rec
Loading details…
</p>
) : (
<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-2" data-qqq-id="search-preview-fields">
<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2" data-qqq-id="search-preview-fields">
{fields.map((field) => (
<div key={field.name} className="min-w-0">
<dt className="text-xs font-medium text-muted-foreground">{field.label}</dt>
<dd className="min-w-0 break-words text-sm text-foreground">
<FieldValue field={field} record={record} tableMetaData={table} thumbnailSize={96} />
<PreviewValue field={field} record={record} table={table} />
</dd>
</div>
))}
+3 -3
View File
@@ -72,7 +72,7 @@ function RelevanceMeter({ value }: { value: number }) {
const label = steps === 3 ? 'Strong match' : steps === 2 ? 'Good match' : 'Partial match'
return (
<span className="inline-flex items-center gap-1" title={`${label} (${Math.round(value * 100)}% of the best result)`} data-qqq-id="search-result-relevance">
<span className="flex items-end gap-px" aria-hidden="true">
<span className="flex items-end gap-0.5" aria-hidden="true">
{[1, 2, 3].map((step) => (
<span key={step} className={cn('w-1 rounded-sm', step === 1 ? 'h-1.5' : step === 2 ? 'h-2' : 'h-2.5', step <= steps ? 'bg-primary' : 'bg-muted-foreground/30')} />
))}
@@ -124,7 +124,7 @@ export function SearchResultCard({ result, tableLabel, icon, term, showTable = f
return (
<li
className={cn('group relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-accent/40 sm:px-6', active && 'bg-accent/60 shadow-[inset_3px_0_0_var(--color-primary)]')}
className={cn('group relative flex items-start gap-3 px-4 py-3 transition-colors hover:bg-accent/50 sm:px-6', active && 'bg-accent shadow-[inset_3px_0_0_var(--color-primary)]')}
data-qqq-id="search-result-card"
data-active={active || undefined}
onMouseEnter={onActivate}
@@ -179,7 +179,7 @@ export function SearchResultCard({ result, tableLabel, icon, term, showTable = f
</dl>
)}
{(details.length > 0 || result.updatedAt) && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
{details.map((detail) => (
<span
key={detail.fieldName}
+74 -1
View File
@@ -23,8 +23,81 @@
* fallback matches). Every extension field is optional.
*/
import { z } from 'zod'
import apiClient from './client'
import { RecordSearchResponseSchema, RecordSearchResultSchema } from './schemas'
// The response schemas live here, not in ./schemas: only record search uses them, and
// keeping them out of the shared module keeps them out of every screen's chunks.
/** A `[start, end)` character range of a match inside a highlight's text. */
const RecordSearchMatchSchema = z.object({ start: z.number().int().nonnegative(), end: z.number().int().nonnegative() })
/**
* One highlighted field of a record search result: plain text (never HTML) plus the
* ranges that matched the search term.
*/
export const RecordSearchHighlightSchema = z.object({
fieldName: z.string(),
fieldLabel: z.string().nullish(),
text: z.string(),
matches: z.array(RecordSearchMatchSchema).nullish(),
})
/** A secondary (non-matching) field value shown under a result's label. */
export const RecordSearchDetailSchema = z.object({
fieldName: z.string(),
fieldLabel: z.string().nullish(),
displayValue: z.string(),
})
/**
* Zod schema for one record search result. `tableName` and `recordId` are required (a
* result cannot link anywhere without them); `recordId` may arrive as a number. Labels may
* be missing, null or empty: QQQ leaves empty values out of its JSON, so a record whose
* label fields are empty arrives without `recordLabel`, and callers label it themselves.
* The extension fields (score, highlights, details, updatedAt) are optional, and a
* malformed one is dropped (`catch`) instead of failing the result.
*/
export const RecordSearchResultSchema = z.object({
tableName: z.string().min(1),
tableLabel: z.string().nullish().transform((value) => value || undefined),
recordId: z.union([z.string().min(1), z.number()]).transform(String),
recordLabel: z.string().nullish().transform((value) => value || undefined),
score: z.number().nullish().catch(undefined),
updatedAt: z.string().nullish().catch(undefined),
highlights: z.array(RecordSearchHighlightSchema).nullish().catch(undefined),
details: z.array(RecordSearchDetailSchema).nullish().catch(undefined),
})
/** Per-table summary of a record search (newer backends). */
export const RecordSearchTableSchema = z.object({
tableName: z.string(),
tableLabel: z.string().nullish(),
resultCount: z.number().nullish(),
hasMore: z.boolean().nullish(),
totalCount: z.number().nullish(),
source: z.string().nullish(),
})
/** Status of the instance's record search provider (e.g. a search index), newer backends. */
export const RecordSearchProviderStatusSchema = z.object({
status: z.string(),
failureClass: z.string().nullish(),
message: z.string().nullish(),
})
/**
* Zod schema for the record search response: `{ results: [...] }`, plus the optional
* per-table summaries and provider status of newer backends. Each result is validated
* separately ({@link RecordSearchResultSchema}), so one malformed result is skipped
* rather than failing the whole search.
*/
export const RecordSearchResponseSchema = z.object({
results: z.array(z.unknown()),
tables: z.array(RecordSearchTableSchema).nullish().catch(undefined),
provider: RecordSearchProviderStatusSchema.nullish().catch(undefined),
})
/** A `[start, end)` character range of a match inside a highlight's text. */
export interface RecordSearchMatch {
+1 -2
View File
@@ -27,10 +27,9 @@ import {
QRecordSchema,
QueryRecordsResponseSchema,
CountRecordsResponseSchema,
RecordSearchResultSchema,
RecordSearchResponseSchema,
QInstanceMinimalSchema,
} from './schemas'
import { RecordSearchResultSchema, RecordSearchResponseSchema } from './record-search'
// ---------------------------------------------------------------------------
// QRecordSchema
-73
View File
@@ -93,79 +93,6 @@ export const CountRecordsResponseSchema = z.object({
distinctCount: z.number().optional(),
})
// ---------------------------------------------------------------------------
// Record search — POST /search
// ---------------------------------------------------------------------------
/** A `[start, end)` character range of a match inside a highlight's text. */
const RecordSearchMatchSchema = z.object({ start: z.number().int().nonnegative(), end: z.number().int().nonnegative() })
/**
* One highlighted field of a record search result: plain text (never HTML) plus the
* ranges that matched the search term.
*/
export const RecordSearchHighlightSchema = z.object({
fieldName: z.string(),
fieldLabel: z.string().nullish(),
text: z.string(),
matches: z.array(RecordSearchMatchSchema).nullish(),
})
/** A secondary (non-matching) field value shown under a result's label. */
export const RecordSearchDetailSchema = z.object({
fieldName: z.string(),
fieldLabel: z.string().nullish(),
displayValue: z.string(),
})
/**
* Zod schema for one record search result. `tableName` and `recordId` are required (a
* result cannot link anywhere without them); `recordId` may arrive as a number. Labels may
* be missing, null or empty: QQQ leaves empty values out of its JSON, so a record whose
* label fields are empty arrives without `recordLabel`, and callers label it themselves.
* The extension fields (score, highlights, details, updatedAt) are optional, and a
* malformed one is dropped (`catch`) instead of failing the result.
*/
export const RecordSearchResultSchema = z.object({
tableName: z.string().min(1),
tableLabel: z.string().nullish().transform((value) => value || undefined),
recordId: z.union([z.string().min(1), z.number()]).transform(String),
recordLabel: z.string().nullish().transform((value) => value || undefined),
score: z.number().nullish().catch(undefined),
updatedAt: z.string().nullish().catch(undefined),
highlights: z.array(RecordSearchHighlightSchema).nullish().catch(undefined),
details: z.array(RecordSearchDetailSchema).nullish().catch(undefined),
})
/** Per-table summary of a record search (newer backends). */
export const RecordSearchTableSchema = z.object({
tableName: z.string(),
tableLabel: z.string().nullish(),
resultCount: z.number().nullish(),
hasMore: z.boolean().nullish(),
totalCount: z.number().nullish(),
source: z.string().nullish(),
})
/** Status of the instance's record search provider (e.g. a search index), newer backends. */
export const RecordSearchProviderStatusSchema = z.object({
status: z.string(),
failureClass: z.string().nullish(),
message: z.string().nullish(),
})
/**
* Zod schema for the record search response: `{ results: [...] }`, plus the optional
* per-table summaries and provider status of newer backends. Each result is validated
* separately ({@link RecordSearchResultSchema}), so one malformed result is skipped
* rather than failing the whole search.
*/
export const RecordSearchResponseSchema = z.object({
results: z.array(z.unknown()),
tables: z.array(RecordSearchTableSchema).nullish().catch(undefined),
provider: RecordSearchProviderStatusSchema.nullish().catch(undefined),
})
// ---------------------------------------------------------------------------
// QInstance — GET /metaData (minimal required-field validation)
// ---------------------------------------------------------------------------