Skip to content

Commit 79d8ad0

Browse files
authored
feat: add persistent options with localStorage (#145)
* feat: add persistent options with localStorage
1 parent a1b1e1f commit 79d8ad0

File tree

5 files changed

+149
-85
lines changed

5 files changed

+149
-85
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ export interface Options {
225225
* Maximum number of renders for red indicator
226226
*
227227
* @default 20
228+
* @deprecated
228229
*/
229230
maxRenders?: number;
230231

packages/scan/src/core/index.ts

Lines changed: 118 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import { playGeigerClickSound } from '@web-utils/geiger';
2626
import { ICONS } from '@web-assets/svgs/svgs';
2727
import { updateFiberRenderData, type RenderData } from 'src/core/utils';
28+
import { readLocalStorage, saveLocalStorage } from '@web-utils/helpers';
2829
import { initReactScanOverlay } from './web/overlay';
2930
import { createInstrumentation, type Render } from './instrumentation';
3031
import { createToolbar } from './web/toolbar';
@@ -33,6 +34,7 @@ import { type getSession } from './monitor/utils';
3334
import styles from './web/assets/css/styles.css';
3435

3536
let toolbarContainer: HTMLElement | null = null;
37+
let shadowRoot: ShadowRoot | null = null;
3638

3739
export interface Options {
3840
/**
@@ -213,6 +215,74 @@ export const ReactScanInternals: Internals = {
213215
Store,
214216
};
215217

218+
type LocalStorageOptions = Omit<Options,
219+
| 'onCommitStart'
220+
| 'onRender'
221+
| 'onCommitFinish'
222+
| 'onPaintStart'
223+
| 'onPaintFinish'
224+
>;
225+
226+
const validateOptions = (options: Partial<Options>): Partial<Options> => {
227+
const errors: Array<string> = [];
228+
const validOptions: Partial<Options> = {};
229+
230+
Object.entries(options).forEach(([key, value]) => {
231+
switch (key) {
232+
case 'enabled':
233+
case 'includeChildren':
234+
case 'playSound':
235+
case 'log':
236+
case 'showToolbar':
237+
case 'report':
238+
case 'alwaysShowLabels':
239+
case 'dangerouslyForceRunInProduction':
240+
if (typeof value !== 'boolean') {
241+
errors.push(`- ${key} must be a boolean. Got "${value}"`);
242+
} else {
243+
(validOptions as any)[key] = value;
244+
}
245+
break;
246+
case 'renderCountThreshold':
247+
case 'resetCountTimeout':
248+
if (typeof value !== 'number' || value < 0) {
249+
errors.push(`- ${key} must be a non-negative number. Got "${value}"`);
250+
} else {
251+
(validOptions as any)[key] = value;
252+
}
253+
break;
254+
case 'animationSpeed':
255+
if (!['slow', 'fast', 'off'].includes(value as string)) {
256+
errors.push(`- Invalid animation speed "${value}". Using default "fast"`);
257+
} else {
258+
(validOptions as any)[key] = value;
259+
}
260+
break;
261+
case 'onCommitStart':
262+
case 'onCommitFinish':
263+
case 'onRender':
264+
case 'onPaintStart':
265+
case 'onPaintFinish':
266+
if (typeof value !== 'function') {
267+
errors.push(`- ${key} must be a function. Got "${value}"`);
268+
} else {
269+
(validOptions as any)[key] = value;
270+
}
271+
break;
272+
default:
273+
errors.push(`- Unknown option "${key}"`);
274+
}
275+
});
276+
277+
if (errors.length > 0) {
278+
// eslint-disable-next-line no-console
279+
console.warn(`[React Scan] Invalid options:\n${errors.join('\n')}`);
280+
return {};
281+
}
282+
283+
return validOptions;
284+
};
285+
216286
export const getReport = (type?: React.ComponentType<any>) => {
217287
if (type) {
218288
for (const reportData of Array.from(Store.legacyReportData.values())) {
@@ -225,32 +295,57 @@ export const getReport = (type?: React.ComponentType<any>) => {
225295
return Store.legacyReportData;
226296
};
227297

228-
export const setOptions = (options: Options) => {
298+
const initializeScanOptions = (userOptions?: Partial<Options>) => {
299+
const options = ReactScanInternals.options.value;
300+
301+
const localStorageOptions = readLocalStorage<LocalStorageOptions>('react-scan-options');
302+
if (localStorageOptions) {
303+
(Object.keys(localStorageOptions) as Array<keyof LocalStorageOptions>).forEach(key => {
304+
const value = localStorageOptions[key];
305+
if (key in options && value !== null) {
306+
(options as any)[key] = value;
307+
}
308+
});
309+
}
310+
311+
ReactScanInternals.options.value = validateOptions({
312+
...options,
313+
...userOptions,
314+
});
315+
316+
saveLocalStorage('react-scan-options', ReactScanInternals.options.value);
317+
318+
return ReactScanInternals.options.value;
319+
};
320+
321+
export const setOptions = (userOptions: Partial<Options>) => {
322+
const validOptions = validateOptions(userOptions);
323+
324+
if (Object.keys(validOptions).length === 0) {
325+
return;
326+
}
327+
229328
const { instrumentation } = ReactScanInternals;
230329
if (instrumentation) {
231-
instrumentation.isPaused.value = options.enabled === false;
330+
instrumentation.isPaused.value = validOptions.enabled === false;
232331
}
233332

234-
const previousOptions = ReactScanInternals.options.value;
333+
const newOptions = initializeScanOptions(validOptions);
235334

236-
ReactScanInternals.options.value = {
237-
...ReactScanInternals.options.value,
238-
...options,
239-
};
335+
if (toolbarContainer && !newOptions.showToolbar) {
336+
toolbarContainer.remove();
337+
}
240338

241-
if (previousOptions.showToolbar && !options.showToolbar) {
242-
if (toolbarContainer) {
243-
toolbarContainer.remove();
244-
toolbarContainer = null;
245-
}
339+
if (newOptions.showToolbar && toolbarContainer && shadowRoot) {
340+
toolbarContainer = createToolbar(shadowRoot);
246341
}
247342
};
248343

249344
export const getOptions = () => ReactScanInternals.options;
250345

251346
export const reportRender = (fiber: Fiber, renders: Array<Render>) => {
252347
const reportFiber = fiber;
253-
const { selfTime } = getTimings(fiber);
348+
const { selfTime } = getTimings(fiber.type);
254349
const displayName = getDisplayName(fiber.type);
255350

256351
Store.lastReportTime.value = performance.now();
@@ -354,6 +449,11 @@ export const start = () => {
354449
!ReactScanInternals.options.value.dangerouslyForceRunInProduction
355450
) {
356451
setOptions({ enabled: false, showToolbar: false });
452+
// eslint-disable-next-line no-console
453+
console.warn(
454+
'[React Scan] Running in production mode is not recommended.\n' +
455+
'If you really need this, set dangerouslyForceRunInProduction: true in options.'
456+
);
357457
return;
358458
}
359459

@@ -365,7 +465,7 @@ export const start = () => {
365465
const container = document.createElement('div');
366466
container.id = 'react-scan-root';
367467

368-
const shadow = container.attachShadow({ mode: 'open' });
468+
shadowRoot = container.attachShadow({ mode: 'open' });
369469

370470
const fragment = document.createDocumentFragment();
371471

@@ -376,7 +476,7 @@ export const start = () => {
376476
ICONS,
377477
'image/svg+xml',
378478
).documentElement;
379-
shadow.appendChild(iconSprite);
479+
shadowRoot.appendChild(iconSprite);
380480

381481
const root = document.createElement('div');
382482
root.id = 'react-scan-toolbar-root';
@@ -385,22 +485,22 @@ export const start = () => {
385485
fragment.appendChild(cssStyles);
386486
fragment.appendChild(root);
387487

388-
shadow.appendChild(fragment);
488+
shadowRoot.appendChild(fragment);
389489

390490
document.documentElement.appendChild(container);
391491

392492
ctx = initReactScanOverlay();
393493
if (!ctx) return;
394494
startFlushOutlineInterval(ctx);
395495

396-
createInspectElementStateMachine(shadow);
496+
createInspectElementStateMachine(shadowRoot);
397497

398498
globalThis.__REACT_SCAN__ = {
399499
ReactScanInternals,
400500
};
401501

402502
if (ReactScanInternals.options.value.showToolbar) {
403-
toolbarContainer = createToolbar(shadow);
503+
toolbarContainer = createToolbar(shadowRoot);
404504
}
405505

406506
container.setAttribute('part', 'scan-root');

packages/scan/src/core/web/assets/css/styles.tailwind.css

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,6 @@ button {
149149
}
150150
}
151151

152-
/* pivanov */
153-
154-
155152
.react-scan-inspector {
156153
font-size: 13px;
157154
color: #fff;

packages/scan/src/core/web/components/widget/toolbar.tsx

Lines changed: 30 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useCallback, useEffect, useMemo } from 'preact/hooks';
2-
import { cn, readLocalStorage, saveLocalStorage } from '@web-utils/helpers';
2+
import { cn } from '@web-utils/helpers';
33
import { ReactScanInternals, Store, setOptions } from '../../../..';
44
import { getNearestFiberFromElement } from '../../inspect-element/utils';
55
import { Icon } from '../icon';
@@ -12,7 +12,6 @@ interface ToolbarProps {
1212
export const Toolbar = ({ refPropContainer }: ToolbarProps) => {
1313
const inspectState = Store.inspectState;
1414

15-
const instrumentation = ReactScanInternals.instrumentation;
1615
const isInspectFocused = inspectState.value.kind === 'focused';
1716
const isInspectActive = inspectState.value.kind === 'inspecting';
1817

@@ -62,85 +61,68 @@ export const Toolbar = ({ refPropContainer }: ToolbarProps) => {
6261
}
6362
}, [Store.inspectState.value]);
6463

65-
const onPreviousFocus = useCallback(() => {
66-
const currentState = Store.inspectState.value;
67-
if (currentState.kind !== 'focused' || !currentState.focusedDomElement)
68-
return;
69-
70-
const focusedDomElement = currentState.focusedDomElement;
64+
const findNextElement = useCallback((
65+
currentElement: HTMLElement,
66+
direction: 'next' | 'previous'
67+
) => {
7168
const allElements = Array.from(document.querySelectorAll('*')).filter(
7269
(el): el is HTMLElement => el instanceof HTMLElement,
7370
);
74-
const currentIndex = allElements.indexOf(focusedDomElement);
75-
if (currentIndex === -1) return;
71+
const currentIndex = allElements.indexOf(currentElement);
72+
if (currentIndex === -1) return null;
7673

77-
let prevElement: HTMLElement | null = null;
78-
let prevIndex = currentIndex - 1;
79-
const currentFiber = getNearestFiberFromElement(focusedDomElement);
74+
const currentFiber = getNearestFiberFromElement(currentElement);
75+
const increment = direction === 'next' ? 1 : -1;
76+
let index = currentIndex + increment;
8077

81-
while (prevIndex >= 0) {
82-
const fiber = getNearestFiberFromElement(allElements[prevIndex]);
78+
while (index >= 0 && index < allElements.length) {
79+
const fiber = getNearestFiberFromElement(allElements[index]);
8380
if (fiber && fiber !== currentFiber) {
84-
prevElement = allElements[prevIndex];
85-
break;
81+
return allElements[index];
8682
}
87-
prevIndex--;
83+
index += increment;
8884
}
85+
return null;
86+
}, []);
87+
88+
const onPreviousFocus = useCallback(() => {
89+
const currentState = Store.inspectState.value;
90+
if (currentState.kind !== 'focused' || !currentState.focusedDomElement) return;
8991

92+
const prevElement = findNextElement(currentState.focusedDomElement, 'previous');
9093
if (prevElement) {
9194
Store.inspectState.value = {
9295
kind: 'focused',
9396
focusedDomElement: prevElement,
9497
propContainer: currentState.propContainer,
9598
};
9699
}
97-
}, [Store.inspectState.value]);
100+
}, [findNextElement]);
98101

99102
const onNextFocus = useCallback(() => {
100103
const currentState = Store.inspectState.value;
101-
if (currentState.kind !== 'focused' || !currentState.focusedDomElement)
102-
return;
103-
104-
const focusedDomElement = currentState.focusedDomElement;
105-
const allElements = Array.from(document.querySelectorAll('*')).filter(
106-
(el): el is HTMLElement => el instanceof HTMLElement,
107-
);
108-
const currentIndex = allElements.indexOf(focusedDomElement);
109-
if (currentIndex === -1) return;
110-
111-
let nextElement: HTMLElement | null = null;
112-
let nextIndex = currentIndex + 1;
113-
const prevFiber = getNearestFiberFromElement(focusedDomElement);
114-
115-
while (nextIndex < allElements.length) {
116-
const fiber = getNearestFiberFromElement(allElements[nextIndex]);
117-
if (fiber && fiber !== prevFiber) {
118-
nextElement = allElements[nextIndex];
119-
break;
120-
}
121-
nextIndex++;
122-
}
104+
if (currentState.kind !== 'focused' || !currentState.focusedDomElement) return;
123105

106+
const nextElement = findNextElement(currentState.focusedDomElement, 'next');
124107
if (nextElement) {
125108
Store.inspectState.value = {
126109
kind: 'focused',
127110
focusedDomElement: nextElement,
128111
propContainer: currentState.propContainer,
129112
};
130113
}
131-
}, [Store.inspectState.value]);
114+
}, [findNextElement]);
132115

133116
const onToggleActive = useCallback(() => {
134-
if (instrumentation) {
135-
instrumentation.isPaused.value = !instrumentation.isPaused.value;
136-
saveLocalStorage('react-scan-paused', instrumentation.isPaused.value);
117+
if (ReactScanInternals.instrumentation) {
118+
ReactScanInternals.instrumentation.isPaused.value = !ReactScanInternals.instrumentation.isPaused.value;
137119
}
138-
}, []);
120+
}, [ReactScanInternals.instrumentation]);
121+
139122

140123
const onSoundToggle = useCallback(() => {
141124
const newSoundState = !ReactScanInternals.options.value.playSound;
142-
setOptions({ playSound: newSoundState, showToolbar: true });
143-
saveLocalStorage('react-scan-sound', newSoundState);
125+
setOptions({ playSound: newSoundState });
144126
}, []);
145127

146128
useEffect(() => {
@@ -154,20 +136,6 @@ export const Toolbar = ({ refPropContainer }: ToolbarProps) => {
154136
}
155137
}, []);
156138

157-
useEffect(() => {
158-
if (instrumentation) {
159-
const savedStatePaused = readLocalStorage<boolean>('react-scan-paused');
160-
if (savedStatePaused !== null) {
161-
instrumentation.isPaused.value = savedStatePaused;
162-
}
163-
164-
const savedStateSound = readLocalStorage<boolean>('react-scan-sound');
165-
if (savedStateSound !== null) {
166-
setOptions({ playSound: savedStateSound, showToolbar: true });
167-
}
168-
}
169-
}, []);
170-
171139
return (
172140
<div className="flex max-h-9 min-h-9 flex-1 items-stretch overflow-hidden">
173141
<button

0 commit comments

Comments
 (0)