This commit is contained in:
seb
2026-10-05 08:31:13 +02:00
parent 4d653304cd
commit 8010436157
5 changed files with 295 additions and 151 deletions

View File

@@ -98,6 +98,7 @@ function scheduleRebuild() {
function toIndexRecord(article, categoryId) { function toIndexRecord(article, categoryId) {
const eans = article.eans ?? article.eanCodes ?? article.ean ?? null; const eans = article.eans ?? article.eanCodes ?? article.ean ?? null;
return { return {
...article,
id: article.id, id: article.id,
sku: article.sku, sku: article.sku,
name: article.name, name: article.name,

View File

@@ -1,23 +1,29 @@
/** /**
* @file articleSearchAutomat.js * @file articleSearchAutomat.js
* @description In-memory article search state for the `/artikel` overview and * @description In-memory article search and pagination state for the `/artikel`
* search pages, querying against the derived `articleIndexAutomat`. * overview and search pages, querying against the derived `articleIndexAutomat`.
* *
* Upstream Triggers: * Upstream Triggers:
* - UI / Search input: Components call `actions.search(query)` or `actions.reset()`. * - UI / Search input / FillSpace: Components call `actions.search({ target, filter, offset, pageSize })`.
* - Reads data from `articleIndexAutomat`: Ensures the derived index is available * - Reads data from `articleIndexAutomat`: Ensures the derived index is available
* before filtering. The index is built from the loaded category views, so a * before filtering and slicing.
* search never issues a request of its own. * - `categoryAutomat`: Provides category hierarchy metadata for faceted counts.
*/ */
import { Automat } from 'react-automat'; import { Automat } from 'react-automat';
import articleIndexAutomat from './articleIndexAutomat'; import articleIndexAutomat from './articleIndexAutomat';
import categoryAutomat from './categoryAutomat';
import { parseFilter } from '../components/article/searchFormat';
/** /**
* @typedef {Object} ArticleSearchResult * @typedef {Object} ArticleSearchResult
* @property {Record<string,object>} categoryFacets Facet counts per category id. * @property {Record<string,object>} categoryFacets Facet counts per category id.
* @property {number} totalCount Number of matching records. * @property {number} totalCount Number of matching records.
* @property {Array<object>} recordset Matching article index records. * @property {Array<object>} recordset Sliced matching article index records for the current page.
* @property {number} offset Current 0-based offset.
* @property {number|null} pageSize Current page size.
* @property {number} pageCount Total number of pages.
* @property {number} currentPage Current 1-based page number.
* @property {Error|null} error Search failure, or `null`. * @property {Error|null} error Search failure, or `null`.
*/ */
@@ -25,6 +31,9 @@ import articleIndexAutomat from './articleIndexAutomat';
* @typedef {Object} ArticleSearchState * @typedef {Object} ArticleSearchState
* @property {ArticleSearchResult} result Latest search outcome. * @property {ArticleSearchResult} result Latest search outcome.
* @property {string} target Active search term. * @property {string} target Active search term.
* @property {string} filter Active category filter string.
* @property {number} offset Current 0-based offset.
* @property {number|null} pageSize Current page size.
* @property {boolean} loading Whether a search is in flight. * @property {boolean} loading Whether a search is in flight.
*/ */
@@ -33,6 +42,10 @@ const emptyResult = {
categoryFacets: {}, categoryFacets: {},
totalCount: 0, totalCount: 0,
recordset: [], recordset: [],
offset: 0,
pageSize: null,
pageCount: 1,
currentPage: 1,
error: null, error: null,
}; };
@@ -40,73 +53,210 @@ const emptyResult = {
const initialState = { const initialState = {
result: emptyResult, result: emptyResult,
target: '', target: '',
filter: '',
offset: 0,
pageSize: null,
loading: false, loading: false,
}; };
let articleSearchAutomat; let articleSearchAutomat;
/** /**
* Matches one index record against a lowercased search term. * Matches one index record against a search query (checks tokens in name, sku, brand, eans).
* *
* @param {object} row Article index record. * @param {object} row Article index record.
* @param {string} query Lowercased search term. * @param {string} query Lowercased search term.
* @returns {boolean} Whether the record matches. * @returns {boolean} Whether the record matches.
*/ */
function matches(row, query) { function matches(row, query) {
if (String(row.name ?? '').toLowerCase().includes(query)) return true; if (!query) return true;
if (String(row.sku ?? '').toLowerCase().includes(query)) return true; const tokens = query.split(/\s+/).filter(Boolean);
return (row.eans ?? []).some((ean) => String(ean).includes(query)); if (tokens.length === 0) return true;
const name = String(row.name ?? '').toLowerCase();
const sku = String(row.sku ?? '').toLowerCase();
const brand = String(row.brand?.name || row.brandName || '').toLowerCase();
const eans = (row.eans ?? []).map((e) => String(e).toLowerCase());
return tokens.every(
(token) =>
name.includes(token) ||
sku.includes(token) ||
brand.includes(token) ||
eans.some((ean) => ean.includes(token)),
);
}
/**
* Checks whether an article belongs to at least one of the selected categories.
*
* @param {object} row Article index record.
* @param {Array<string>} selectedCategories Array of category ID strings.
* @returns {boolean} Whether the article matches.
*/
function matchesCategory(row, selectedCategories) {
if (!selectedCategories || selectedCategories.length === 0) return true;
if (!row.categories) return false;
const rowCats = String(row.categories).split(',').map((s) => s.trim());
return selectedCategories.some((catId) => rowCats.includes(String(catId)));
}
/**
* Computes facet counts for each category represented in matched articles.
*
* @param {Array<object>} matchedArticles Articles matching text query.
* @returns {Record<string, {count: number, name: string, parent: any}>} Category facets.
*/
function buildCategoryFacets(matchedArticles) {
const facets = {};
const categoriesMap = categoryAutomat.state?.categories ?? {};
for (const article of matchedArticles) {
if (!article.categories) continue;
const ids = String(article.categories).split(',').map((s) => s.trim()).filter(Boolean);
for (const id of ids) {
if (!facets[id]) {
const catNode = categoriesMap[id];
facets[id] = {
count: 0,
name: catNode?.name ?? `Kategorie ${id}`,
parent: catNode?.parentId ?? null,
};
}
facets[id].count += 1;
}
}
return facets;
} }
const actions = { const actions = {
/** /**
* Runs a client-side search over the article index. * Runs a client-side search over the article index with pagination.
* *
* The index is already derived from the cached category views, so this does * @param {string|object} targetOrParams Search term or options object.
* not hit the network once the tree has loaded. * @param {object} [options] Secondary options when first argument is target string.
*
* @param {string} target Search term; an empty string returns everything.
* @returns {Promise<ArticleSearchResult>} Search outcome. * @returns {Promise<ArticleSearchResult>} Search outcome.
*/ */
async search(target) { async search(targetOrParams, options = {}) {
articleSearchAutomat.setState({ target, loading: true }); const currentState = articleSearchAutomat.state;
const params = typeof targetOrParams === 'object' && targetOrParams !== null
? targetOrParams
: { target: targetOrParams, ...options };
const target = params.target !== undefined ? String(params.target ?? '') : currentState.target;
const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter;
const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize;
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
articleSearchAutomat.setState({ target, filter, offset, pageSize, loading: true });
try { try {
const articles = await articleIndexAutomat.actions.ensureLoaded(); const articles = await articleIndexAutomat.actions.ensureLoaded();
const query = String(target ?? '').trim().toLowerCase(); const query = target.trim().toLowerCase();
const recordset = query
// Filter by text query across all tokens
const textMatches = query
? articles.filter((row) => matches(row, query)) ? articles.filter((row) => matches(row, query))
: articles; : articles;
// Build facets for all text matches (before category filtering)
const categoryFacets = buildCategoryFacets(textMatches);
// Apply category filter if set
const selectedCategories = parseFilter(filter);
const filteredArticles = selectedCategories.length > 0
? textMatches.filter((row) => matchesCategory(row, selectedCategories))
: textMatches;
const totalCount = filteredArticles.length;
const safePageSize = pageSize != null && pageSize > 0 ? pageSize : null;
const pageCount = safePageSize ? Math.max(1, Math.ceil(totalCount / safePageSize)) : 1;
const currentPage = safePageSize ? Math.floor(offset / safePageSize) + 1 : 1;
const recordset = safePageSize
? filteredArticles.slice(offset, offset + safePageSize)
: filteredArticles;
const result = { const result = {
categoryFacets: {}, categoryFacets,
totalCount: recordset.length, totalCount,
recordset, recordset,
offset,
pageSize: safePageSize,
pageCount,
currentPage,
error: null, error: null,
}; };
articleSearchAutomat.setState({ result, loading: false }); articleSearchAutomat.setState({ result, loading: false });
return result; return result;
} catch (error) { } catch (error) {
const result = { ...emptyResult, error }; const result = {
...emptyResult,
pageSize,
offset,
error,
};
articleSearchAutomat.setState({ result, loading: false }); articleSearchAutomat.setState({ result, loading: false });
throw error; throw error;
} }
}, },
/** Reset the result set. */ /**
* Updates the page size and re-derives the result set.
*
* @param {number} pageSize Number of articles per page.
* @returns {Promise<ArticleSearchResult>}
*/
setPageSize(pageSize) {
const { target, filter, offset } = articleSearchAutomat.state;
return actions.search({ target, filter, offset, pageSize });
},
/**
* Sets page number (1-based) and re-derives the result set.
*
* @param {number} page 1-based page number.
* @returns {Promise<ArticleSearchResult>}
*/
setPage(page) {
const { target, filter, pageSize } = articleSearchAutomat.state;
const safeSize = pageSize ?? 1;
const offset = Math.max(0, (page - 1) * safeSize);
return actions.search({ target, filter, offset, pageSize });
},
/**
* Sets 0-based offset and re-derives the result set.
*
* @param {number} offset 0-based record offset.
* @returns {Promise<ArticleSearchResult>}
*/
setOffset(offset) {
const { target, filter, pageSize } = articleSearchAutomat.state;
return actions.search({ target, filter, offset, pageSize });
},
/** Reset the search state. */
reset() { reset() {
articleSearchAutomat.setState(initialState); articleSearchAutomat.setState(initialState);
}, },
}; };
/** /**
* Article search state for the `/artikel` overview page. * Article search and pagination state for the `/artikel` overview page.
* *
* Replaces `StorageManager.doSearch()`; components subscribe to `result` * Components subscribe to `result` to receive paginated records, facets,
* instead of receiving a callback. * page counts, and total counts.
* *
* @type {Automat<ArticleSearchState>} * @type {Automat<ArticleSearchState>}
*/ */
articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' }); articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' });
articleIndexAutomat.subscribe(() => {
const { target, filter, offset, pageSize, loading } = articleSearchAutomat.state;
if (pageSize != null && !loading && articleIndexAutomat.state.status === 'ready') {
void actions.search({ target, filter, offset, pageSize }).catch(() => {});
}
});
/** /**
* Selects the latest search result. * Selects the latest search result.
* *

View File

@@ -37,6 +37,19 @@ if (import.meta.env.DEV) {
void clearDevServiceWorker(); void clearDevServiceWorker();
} }
// PWA auto-update: reload the page as soon as a new build's service worker
// takes control. The virtual module only exists in production builds (the
// plugin is registered for `build` only), hence the dynamic import.
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
import('virtual:pwa-register')
.then(({ registerSW }) => {
registerSW({ immediate: true });
})
.catch(() => {
// No SW in this build — app still works, just without offline support.
});
}
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<ThemeProvider theme={getActiveTheme()}> <ThemeProvider theme={getActiveTheme()}>
<CssBaseline /> <CssBaseline />

View File

@@ -32,9 +32,6 @@ import { artikelDetailPath } from '../components/article/articleRoute';
import withSearchParams from '../components/article/withSearchParams'; import withSearchParams from '../components/article/withSearchParams';
import { setLastArticleSku } from '../automats'; import { setLastArticleSku } from '../automats';
import { BrandLabel } from '../components/brand'; import { BrandLabel } from '../components/brand';
import {
BLUE_BG,
} from '../colors';
const resultRowSx = { const resultRowSx = {
alignItems: 'center', alignItems: 'center',
@@ -50,18 +47,31 @@ const resultRowSx = {
function ResultRowMeasureSample() { function ResultRowMeasureSample() {
return ( return (
<ListItemButton tabIndex={-1} divider sx={resultRowSx}> <ListItemButton
tabIndex={-1}
divider
disabled
sx={{
...resultRowSx,
pointerEvents: 'none',
userSelect: 'none',
opacity: 0.2,
}}
>
<ListItemText <ListItemText
primary="Measure" primary={'\u00A0'}
secondary={ secondary={
<Typography component="span" variant="body2" color="text.secondary" noWrap> <Typography component="span" variant="body2" sx={{ visibility: 'hidden' }} noWrap>
SKU · Bestand 0 · Verfügbar 0 · Kategorie &nbsp;
</Typography> </Typography>
} }
slotProps={{ primary: { fontWeight: 500, noWrap: true } }} slotProps={{ primary: { fontWeight: 500, noWrap: true } }}
/> />
<Typography variant="body2" sx={{ fontWeight: 600, flexShrink: 0, ml: 2 }}> <Typography
0,00 € variant="body2"
sx={{ fontWeight: 600, flexShrink: 0, ml: 2, visibility: 'hidden' }}
>
&nbsp;
</Typography> </Typography>
</ListItemButton> </ListItemButton>
); );
@@ -75,87 +85,35 @@ function initialSearchState(searchParams) {
? query.target ? query.target
: (stored?.target?.trim().length >= 3 ? stored.target : query.target); : (stored?.target?.trim().length >= 3 ? stored.target : query.target);
const automatResult = articleSearchAutomat.state.result;
return { return {
input, input,
error: null, error: automatResult.error,
categoryFacets: null, categoryFacets: automatResult.categoryFacets,
totalCount: null, totalCount: automatResult.totalCount,
recordset: null, recordset: automatResult.recordset,
pageSize: null, pageSize: automatResult.pageSize,
pageCount: automatResult.pageCount,
currentPage: automatResult.currentPage,
offset: automatResult.offset,
}; };
} }
class ArtikelSearchPage extends Component { class ArtikelSearchPage extends Component {
state = initialSearchState(this.props.searchParams); state = initialSearchState(this.props.searchParams);
listSlotRef = { current: null };
rowMeasureRef = { current: null };
paginationRef = createRef(); paginationRef = createRef();
setListSlotRef = (node) => { handleMeasurement = (count) => {
if (this.listSlotRef.current === node) return; if (!count || count === this.state.pageSize) return;
this.listSlotRef.current = node; this.setState({ pageSize: count });
if (!node) return; const { target, filter, offset } = queryFromParams(this.props.searchParams);
this.updatePageSize(); void articleSearchAutomat.actions.search({
}; target: target || this.state.input,
filter,
setRowMeasureRef = (node) => { offset,
if (this.rowMeasureRef.current === node) return; pageSize: count,
this.rowMeasureRef.current = node; }).catch(() => {});
if (!node) return;
this.updatePageSize();
};
measureRowHeight = () => {
// Always use the sample — real rows used to report a different height after paint
// (wrapped text), which made resize "fix" a wrong first measure.
const sample = this.rowMeasureRef.current;
if (!sample) return { height: 0, source: 'none' };
return {
height: sample.getBoundingClientRect().height,
source: 'sample',
};
};
updatePageSize = ({ fromResize = false } = {}) => {
const slot = this.listSlotRef.current;
if (!slot) return;
// After the first measure, ignore layout churn from painting results.
// Only window resize may change pageSize and refetch.
if (this.state.pageSize != null && !fromResize) return;
const slotTop = slot.getBoundingClientRect().top;
const available = window.innerHeight - slotTop;
const { height: rowHeight, source: rowSource } = this.measureRowHeight();
if (available < 1 || rowHeight < 1) return;
// Outlined Paper around the list is 1px top + 1px bottom — not in row height.
const listChrome = 2;
const next = Math.max(1, Math.floor((available - listChrome) / rowHeight));
console.log('[pageSize] measure', {
fromResize,
prevPageSize: this.state.pageSize,
next,
available,
rowHeight,
rowSource,
listChrome,
slotTop,
realRowHeight:
slot.querySelector('[data-result-row]')?.getBoundingClientRect().height ?? null,
});
if (next === this.state.pageSize) return;
this.setState({ pageSize: next }, () => {
const target = this.props.searchParams.get('target') ?? '';
if (target) this.fetchResults();
});
};
handleWindowResize = () => {
this.updatePageSize({ fromResize: true });
}; };
persistQuery = () => { persistQuery = () => {
@@ -170,19 +128,14 @@ class ArtikelSearchPage extends Component {
componentDidMount() { componentDidMount() {
document.addEventListener('mouseup', preserveSearchFocus, true); document.addEventListener('mouseup', preserveSearchFocus, true);
window.addEventListener('resize', this.handleWindowResize);
setLastArticleSku(null); setLastArticleSku(null);
// Push search results into local component state. // Push search results into local component state.
this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result); this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result);
// Kick off initial search (empty input = show all products).
void articleSearchAutomat.actions.search(this.state.input).catch(() => {});
} }
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener('mouseup', preserveSearchFocus, true); document.removeEventListener('mouseup', preserveSearchFocus, true);
window.removeEventListener('resize', this.handleWindowResize);
this.unsubSearch?.(); this.unsubSearch?.();
this.unsubSearch = null; this.unsubSearch = null;
this.persistQuery(); this.persistQuery();
@@ -221,7 +174,16 @@ class ArtikelSearchPage extends Component {
handleInputChange = (event) => { handleInputChange = (event) => {
const value = event.target.value; const value = event.target.value;
this.setState({ input: value }); this.setState({ input: value });
void articleSearchAutomat.actions.search(value).catch(() => {}); const { filter } = queryFromParams(this.props.searchParams);
const { pageSize } = this.state;
if (pageSize != null) {
void articleSearchAutomat.actions.search({
target: value,
filter,
offset: 0,
pageSize,
}).catch(() => {});
}
}; };
fetchResults = () => { fetchResults = () => {
@@ -232,13 +194,36 @@ class ArtikelSearchPage extends Component {
writeArtikelQuery({ target, filter, offset }); writeArtikelQuery({ target, filter, offset });
void articleSearchAutomat.actions.search(target.trim()).catch(() => {}); void articleSearchAutomat.actions.search({
target: target.trim(),
filter,
offset,
pageSize,
}).catch(() => {});
};
handlePageChange = (_e, page) => {
const { pageSize } = this.state;
const safePageSize = pageSize ?? 1;
const nextOffset = (page - 1) * safePageSize;
const filterParam = this.props.searchParams.get('filter') ?? '';
this.updateParams(this.state.input, filterParam, nextOffset);
}; };
render() { render() {
const { input, error, categoryFacets, totalCount, recordset, pageSize } = this.state; const {
input,
error,
categoryFacets,
totalCount,
recordset,
pageSize,
pageCount,
currentPage,
offset: stateOffset,
} = this.state;
const filterParam = this.props.searchParams.get('filter') ?? ''; const filterParam = this.props.searchParams.get('filter') ?? '';
const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0); const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || stateOffset || 0);
const categories = parseFilter(filterParam); const categories = parseFilter(filterParam);
// Drive UI from input so hint vs results switch in one paint (URL lags one tick). // Drive UI from input so hint vs results switch in one paint (URL lags one tick).
const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0; const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0;
@@ -256,16 +241,9 @@ class ArtikelSearchPage extends Component {
.sort((a, b) => Number(a.id) - Number(b.id)) .sort((a, b) => Number(a.id) - Number(b.id))
: []; : [];
const safePageSize = pageSize ?? 1;
const pageCount = Math.max(1, Math.ceil((totalCount ?? 0) / safePageSize));
const currentPage = Math.floor(offset / safePageSize) + 1;
const showPagination = const showPagination =
rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize; rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize;
const handlePageChange = (_e, page) => {
this.updateParams(input, filterParam, (page - 1) * safePageSize);
};
return ( return (
<> <>
<Box sx={{ mb: 3, maxWidth: 720 }}> <Box sx={{ mb: 3, maxWidth: 720 }}>
@@ -340,16 +318,6 @@ class ArtikelSearchPage extends Component {
)} )}
<Box sx={{ flexGrow: 1, minWidth: 0, mb: -4 }}> <Box sx={{ flexGrow: 1, minWidth: 0, mb: -4 }}>
{/* Same column width as the list; invisible, zero layout footprint. */}
<Box aria-hidden sx={{ height: 0, overflow: 'hidden', width: '100%' }}>
<Box
ref={this.setRowMeasureRef}
sx={{ visibility: 'hidden', pointerEvents: 'none' }}
>
<ResultRowMeasureSample />
</Box>
</Box>
{searchActive && !error && totalCount === 0 && ( {searchActive && !error && totalCount === 0 && (
<Typography color="text.secondary">Keine Artikel gefunden.</Typography> <Typography color="text.secondary">Keine Artikel gefunden.</Typography>
)} )}
@@ -363,29 +331,17 @@ class ArtikelSearchPage extends Component {
pageSize={pageSize ?? 1} pageSize={pageSize ?? 1}
pageCount={showPagination ? pageCount : 5} pageCount={showPagination ? pageCount : 5}
currentPage={showPagination ? currentPage : 1} currentPage={showPagination ? currentPage : 1}
onPageChange={handlePageChange} onPageChange={this.handlePageChange}
/> />
</Box> </Box>
<FillSpace <FillSpace
aboveRef={this.paginationRef} aboveRef={this.paginationRef}
debug onMeasurement={this.handleMeasurement}
sx={{ overflow: 'auto' }} sx={{ overflow: 'auto' }}
// Temporary dummy unit so the repeating fill is visible. measureElement={<ResultRowMeasureSample />}
measureElement={
<Box
sx={{
bgcolor: BLUE_BG,
p: 2,
borderRadius: 1,
textAlign: 'center',
}}
>
<Typography variant="body2">Einheit</Typography>
</Box>
}
> >
<Box ref={this.setListSlotRef}> <Box>
{rows.length > 0 && ( {rows.length > 0 && (
<List component={Paper} variant="outlined" disablePadding> <List component={Paper} variant="outlined" disablePadding>
{rows.map((row, index) => ( {rows.map((row, index) => (

View File

@@ -289,8 +289,13 @@ export default defineConfig(({ command }) => ({
...(command === 'build' ...(command === 'build'
? [ ? [
VitePWA({ VitePWA({
// autoUpdate: the SW calls clientsClaim()/skipWaiting() itself, so a
// new build takes over every open tab on its own.
registerType: 'autoUpdate', registerType: 'autoUpdate',
injectRegister: 'auto', // Registration is done explicitly in src/main.jsx via
// `virtual:pwa-register` with `immediate: true`; that is what makes
// the *page* reload (a bare injected script only swaps the SW).
injectRegister: false,
manifest: { manifest: {
id: '/', id: '/',
name: 'Dashfront', name: 'Dashfront',
@@ -320,6 +325,13 @@ export default defineConfig(({ command }) => ({
], ],
}, },
workbox: { workbox: {
// NOTE: vite-plugin-pwa only auto-sets these two when
// injectRegister === 'auto'. Because we register manually in
// src/main.jsx (injectRegister: false), they must be explicit —
// otherwise a new SW waits forever behind the old one and no tab
// is ever claimed.
skipWaiting: true,
clientsClaim: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'],
globIgnores: ['**/*.wasm'], globIgnores: ['**/*.wasm'],
navigateFallback: 'index.html', navigateFallback: 'index.html',
@@ -327,7 +339,19 @@ export default defineConfig(({ command }) => ({
}, },
}), }),
] ]
: []), : [
{
name: 'virtual-pwa-register-stub',
resolveId(id) {
if (id === 'virtual:pwa-register') return '\0virtual:pwa-register';
},
load(id) {
if (id === '\0virtual:pwa-register') {
return 'export function registerSW() { return () => {}; }';
}
},
},
]),
], ],
server: sharedServer, server: sharedServer,
preview: sharedServer, preview: sharedServer,