diff --git a/packages/bippy/src/rdt-hook.ts b/packages/bippy/src/rdt-hook.ts index f015d69..c8cacc8 100644 --- a/packages/bippy/src/rdt-hook.ts +++ b/packages/bippy/src/rdt-hook.ts @@ -78,13 +78,11 @@ export const getRDTHook = (onActive?: () => unknown) => { try { // __REACT_DEVTOOLS_GLOBAL_HOOK__ must exist before React is ever executed if ( - typeof window !== "undefined" && - // @ts-expect-error `document` may not be defined in some enviroments - (window.document?.createElement || - window.navigator?.product === "ReactNative") && - typeof process !== "undefined" && - process.versions != null && - process.versions.node != null + typeof window !== 'undefined' && + typeof window.document !== 'undefined' && + (typeof window.document.createElement === 'function' || + (typeof window.navigator !== 'undefined' && + window.navigator.product === 'ReactNative')) ) { installRDTHook(); } diff --git a/packages/bippy/src/types.ts b/packages/bippy/src/types.ts index 409cdc4..091eebf 100644 --- a/packages/bippy/src/types.ts +++ b/packages/bippy/src/types.ts @@ -123,6 +123,13 @@ export interface ReactRenderer { version: string; bundleType: 0 /* PROD */ | 1 /* DEV */; findFiberByHostInstance?: (hostInstance: unknown) => Fiber | null; + overrideProps?: (fiber: Fiber, path: unknown[], value: unknown) => void; + overrideHookState?: ( + fiber: Fiber, + id: unknown, + path: unknown[], + value: unknown, + ) => void; } export interface ContextDependency { diff --git a/packages/bippy/tsconfig.json b/packages/bippy/tsconfig.json index f2281f5..c4cc6ce 100644 --- a/packages/bippy/tsconfig.json +++ b/packages/bippy/tsconfig.json @@ -1,8 +1,25 @@ { - "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react", + "module": "ESNext", + "target": "ESNext", + "esModuleInterop": true, + "strictNullChecks": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "lib": [ + "esnext", + "dom" + ], + "moduleResolution": "bundler" + }, "include": [ "src", "vitest.config.ts", "tsup.config.ts" + ], + "exclude": [ + "**/node_modules/**", + "dist" ] } diff --git a/packages/scan/package.json b/packages/scan/package.json new file mode 100644 index 0000000..6dd1b69 --- /dev/null +++ b/packages/scan/package.json @@ -0,0 +1,55 @@ +{ + "name": "@bippy/scan", + "private": true, + "type": "module", + "main": "../bippy/dist/scan/index.js", + "module": "../bippy/dist/scan/index.js", + "types": "../bippy/dist/scan/index.d.ts", + "scripts": { + "dev": "NODE_ENV=development vite build --watch", + "build": "vite build", + "dev2": "vite" + }, + "dependencies": { + "@preact/signals": "^1.3.1", + "bippy": "workspace:*", + "preact": "^10.25.1" + }, + "devDependencies": { + "@pivanov/vite-plugin-svg-sprite": "3.0.0-rc-0.0.10", + "@remix-run/react": "*", + "@types/rollup": "^0.54.0", + "autoprefixer": "^10.4.20", + "clsx": "^2.1.1", + "next": "*", + "postcss": "^8.4.49", + "react": "*", + "react-dom": "*", + "react-router": "^5.0.0", + "react-router-dom": "^5.0.0 || ^6.0.0 || ^7.0.0", + "rollup": "^4.29.1", + "tailwind-merge": "^2.6.0", + "tailwindcss": "^3.4.17", + "vite": "^6.0.7", + "vite-plugin-dts": "^4.4.0", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^2.1.8" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "next": { + "optional": true + }, + "react-router": { + "optional": true + }, + "react-router-dom": { + "optional": true + } + }, + "optionalDependencies": { + "unplugin": "2.1.0" + } +} diff --git a/packages/scan/postcss.config.mjs b/packages/scan/postcss.config.mjs new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/packages/scan/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/packages/scan/src/README.md b/packages/scan/src/README.md new file mode 100644 index 0000000..17a0e7d --- /dev/null +++ b/packages/scan/src/README.md @@ -0,0 +1,27 @@ +## todo + +- think of ways to highlight what the actual problems are +- service worker +- port over select tool + +## notes + +- feature compat with existing version (core, instrumentation, highlighting) +- refactor main website +- playwright version + +# react scan v1 architecture + +- `bippy` / `@react-scan/web-vitals` + - `@react-scan/web-instrumentation`: web instrumentation, export API for renders, types of renders, FPS, CWV + - `react-scan`: + - web highlighting overlay + - web toolbar + - `@react-scan/playwright`: + - playwright plugin (... for each testing lib) + - `@react-scan/sdk`: + - observability sdk + - `@react-scan/native-instrumentation`: native instrumentation, export API for renders, types of renders, FPS, CWV + - `@react-scan/native`: + - native highlighting overlay + - native toolbar diff --git a/packages/scan/src/astro.d.ts b/packages/scan/src/astro.d.ts new file mode 100644 index 0000000..6d0fc31 --- /dev/null +++ b/packages/scan/src/astro.d.ts @@ -0,0 +1,6 @@ +/// + +declare module '*.astro' { + const Component: unknown; + export default Component; +} diff --git a/packages/scan/src/core/fast-serialize.test.ts b/packages/scan/src/core/fast-serialize.test.ts new file mode 100644 index 0000000..b5859fe --- /dev/null +++ b/packages/scan/src/core/fast-serialize.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { fastSerialize } from '~core/instrumentation'; + +describe('fastSerialize', () => { + it('serializes null', () => { + expect(fastSerialize(null)).toBe('null'); + }); + + it('serializes undefined', () => { + expect(fastSerialize(undefined)).toBe('undefined'); + }); + + it('serializes strings', () => { + expect(fastSerialize('hello')).toBe('hello'); + expect(fastSerialize('')).toBe(''); + }); + + it('serializes numbers', () => { + expect(fastSerialize(42)).toBe('42'); + expect(fastSerialize(0)).toBe('0'); + expect(fastSerialize(Number.NaN)).toBe('NaN'); + }); + + it('serializes booleans', () => { + expect(fastSerialize(true)).toBe('true'); + expect(fastSerialize(false)).toBe('false'); + }); + + it('serializes functions', () => { + const testFunc = (_x: 2) => 3 + expect(fastSerialize(testFunc)).toBe('(_x) => 3'); + }); + + it('serializes arrays', () => { + expect(fastSerialize([])).toBe('[]'); + expect(fastSerialize([1, 2, 3])).toBe('[3]'); + }); + + it('serializes plain objects', () => { + expect(fastSerialize({})).toBe('{}'); + expect(fastSerialize({ a: 1, b: 2 })).toBe('{2}'); + }); + + it('serializes deeply nested objects with depth limit', () => { + const nested = { a: { b: { c: 1 } } }; + expect(fastSerialize(nested, 0)).toBe('{1}'); + expect(fastSerialize(nested, -1)).toBe('…'); + }); + + it('serializes objects with custom constructors', () => { + // eslint-disable-next-line @typescript-eslint/no-extraneous-class + class CustomClass {} + const instance = new CustomClass(); + expect(fastSerialize(instance)).toBe('CustomClass{…}'); + }); + + it('serializes unknown objects gracefully', () => { + const date = new Date(); + const serialized = fastSerialize(date); + expect(serialized.includes('Date')).toBe(true); + }); +}); diff --git a/packages/scan/src/core/index.ts b/packages/scan/src/core/index.ts new file mode 100644 index 0000000..3ee668d --- /dev/null +++ b/packages/scan/src/core/index.ts @@ -0,0 +1,683 @@ +import { signal, type Signal } from '@preact/signals'; +import { + detectReactBuildType, + getDisplayName, + getRDTHook, + getTimings, + getType, + isCompositeFiber, + isInstrumentationActive, + traverseFiber, + type Fiber, +} from 'bippy'; +import type * as React from 'react'; +import svgSprite from 'virtual:svg-sprite'; +import styles from '~web/assets/css/styles.css?inline'; +import { log, logIntro } from '~web/utils/log'; +import type { States } from '~web/components/inspector/utils'; +import { createToolbar } from '~web/toolbar'; +import { playGeigerClickSound } from '~web/utils/geiger'; +import { saveLocalStorage, readLocalStorage } from '~web/utils/helpers'; +import type { Outline } from '~web/utils/outline'; +import { type RenderData, updateFiberRenderData } from '~core/utils'; +import type { getSession } from './monitor/utils'; +import type { InternalInteraction } from './monitor/types'; +import { createInstrumentation, type Render } from './instrumentation'; +import { initReactScanOverlay } from 'src/outlines'; + +let toolbarContainer: HTMLElement | null = null; +let shadowRoot: ShadowRoot | null = null; +let audioContext: AudioContext | null = null; + +export interface Options { + /** + * Enable/disable scanning + * + * Please use the recommended way: + * enabled: process.env.NODE_ENV === 'development', + * + * @default true + */ + enabled?: boolean; + /** + * Include children of a component applied with withScan + * + * @default true + */ + includeChildren?: boolean; + + /** + * Force React Scan to run in production (not recommended) + * + * @default false + */ + dangerouslyForceRunInProduction?: boolean; + + /** + * Enable/disable geiger sound + * + * @default true + */ + playSound?: boolean; + + /** + * Log renders to the console + * + * WARNING: This can add significant overhead when the app re-renders frequently + * + * @default false + */ + log?: boolean; + + /** + * Show toolbar bar + * + * If you set this to true, and set {@link enabled} to false, the toolbar will still show, but scanning will be disabled. + * + * @default true + */ + showToolbar?: boolean; + + /** + * Render count threshold, only show + * when a component renders more than this + * + * @default 0 + */ + renderCountThreshold?: number; + + /** + * Clear aggregated fibers after this time in milliseconds + * + * @default 5000 + */ + resetCountTimeout?: number; + + /** + * Maximum number of renders for red indicator + * + * @default 20 + * @deprecated + */ + maxRenders?: number; + + /** + * Report data to getReport() + * + * @default false + */ + report?: boolean; + + /** + * Always show labels + * + * @default false + */ + alwaysShowLabels?: boolean; + + /** + * Animation speed + * + * @default "fast" + */ + animationSpeed?: 'slow' | 'fast' | 'off'; + + /** + * Smoothly animate the re-render outline when the element moves + * + * @default true + */ + smoothlyAnimateOutlines?: boolean; + + /** + * Track unnecessary renders, and mark their outlines gray when detected + * + * An unnecessary render is defined as the component re-rendering with no change to the component's + * corresponding dom subtree + * + * @default false + * @warning tracking unnecessary renders can add meaningful overhead to react-scan + */ + trackUnnecessaryRenders?: boolean; + + onCommitStart?: () => void; + onRender?: (fiber: Fiber, renders: Array) => void; + onCommitFinish?: () => void; + onPaintStart?: (outlines: Array) => void; + onPaintFinish?: (outlines: Array) => void; +} + +export type MonitoringOptions = Pick< + Options, + | 'includeChildren' + | 'enabled' + | 'renderCountThreshold' + | 'resetCountTimeout' + | 'onCommitStart' + | 'onCommitFinish' + | 'onPaintStart' + | 'onPaintFinish' + | 'onRender' +>; + +interface Monitor { + pendingRequests: number; + interactions: Array; + session: ReturnType; + url: string | null; + route: string | null; + apiKey: string | null; + commit: string | null; + branch: string | null; +} + +export interface StoreType { + inspectState: Signal; + wasDetailsOpen: Signal; + lastReportTime: Signal; + isInIframe: Signal; + monitor: Signal; + fiberRoots: WeakSet; + reportData: WeakMap; + legacyReportData: Map; +} + +export type OutlineKey = `${string}-${string}`; + +export interface Internals { + instrumentation: ReturnType | null; + componentAllowList: WeakMap, Options> | null; + options: Signal; + scheduledOutlines: Map; // we clear t,his nearly immediately, so no concern of mem leak on the fiber + // outlines at the same coordinates always get merged together, so we pre-compute the merge ahead of time when aggregating in activeOutlines + activeOutlines: Map; // we re-use the outline object on the scheduled outline + onRender: ((fiber: Fiber, renders: Array) => void) | null; + Store: StoreType; +} + +export const Store: StoreType = { + wasDetailsOpen: signal(true), + isInIframe: signal( + typeof window !== 'undefined' && window.self !== window.top, + ), + inspectState: signal({ + kind: 'uninitialized', + }), + monitor: signal(null), + fiberRoots: new WeakSet(), + reportData: new WeakMap(), + legacyReportData: new Map(), + lastReportTime: signal(0), +}; + +export const ReactScanInternals: Internals = { + instrumentation: null, + componentAllowList: null, + options: signal({ + enabled: true, + includeChildren: true, + playSound: false, + log: false, + showToolbar: true, + renderCountThreshold: 0, + report: undefined, + alwaysShowLabels: false, + animationSpeed: 'fast', + dangerouslyForceRunInProduction: false, + smoothlyAnimateOutlines: true, + trackUnnecessaryRenders: false, + }), + onRender: null, + scheduledOutlines: new Map(), + activeOutlines: new Map(), + Store, +}; + +type LocalStorageOptions = Omit< + Options, + | 'onCommitStart' + | 'onRender' + | 'onCommitFinish' + | 'onPaintStart' + | 'onPaintFinish' +>; + +const validateOptions = (options: Partial): Partial => { + const errors: Array = []; + const validOptions: Partial = {}; + + for (const key in options) { + const value = options[key as keyof Options]; + switch (key as keyof Options) { + case 'enabled': + case 'includeChildren': + case 'playSound': + case 'log': + case 'showToolbar': + case 'report': + case 'alwaysShowLabels': + case 'dangerouslyForceRunInProduction': + if (typeof value !== 'boolean') { + errors.push(`- ${key} must be a boolean. Got "${value}"`); + } else { + validOptions[key] = value; + } + break; + case 'renderCountThreshold': + case 'resetCountTimeout': + if (typeof value !== 'number' || value < 0) { + errors.push(`- ${key} must be a non-negative number. Got "${value}"`); + } else { + validOptions[key] = value as number; + } + break; + case 'animationSpeed': + if (!['slow', 'fast', 'off'].includes(value as string)) { + errors.push( + `- Invalid animation speed "${value}". Using default "fast"`, + ); + } else { + validOptions[key] = value as 'slow' | 'fast' | 'off'; + } + break; + case 'onCommitStart': + case 'onCommitFinish': + case 'onRender': + case 'onPaintStart': + case 'onPaintFinish': + if (typeof value !== 'function') { + errors.push(`- ${key} must be a function. Got "${value}"`); + } else { + validOptions[key] = value as Options[keyof Options]; + } + break; + case 'trackUnnecessaryRenders': { + validOptions.trackUnnecessaryRenders = + typeof value === 'boolean' ? value : false; + break; + } + case 'smoothlyAnimateOutlines': { + validOptions.smoothlyAnimateOutlines = + typeof value === 'boolean' ? value : false; + break; + } + default: + errors.push(`- Unknown option "${key}"`); + } + } + + if (errors.length > 0) { + // eslint-disable-next-line no-console + console.warn(`[React Scan] Invalid options:\n${errors.join('\n')}`); + } + + return validOptions; +}; + +export const getReport = (type?: React.ComponentType) => { + if (type) { + for (const reportData of Array.from(Store.legacyReportData.values())) { + if (reportData.type === type) { + return reportData; + } + } + return null; + } + return Store.legacyReportData; +}; + +export const setOptions = (userOptions: Partial) => { + const validOptions = validateOptions(userOptions); + + if (Object.keys(validOptions).length === 0) { + return; + } + + if ('playSound' in validOptions && validOptions.playSound) { + validOptions.enabled = true; + } + + const newOptions = { + ...ReactScanInternals.options.value, + ...validOptions, + }; + + const { instrumentation } = ReactScanInternals; + if (instrumentation && 'enabled' in validOptions) { + instrumentation.isPaused.value = validOptions.enabled === false; + } + + ReactScanInternals.options.value = newOptions; + + saveLocalStorage('react-scan-options', newOptions); + + if ('showToolbar' in validOptions) { + if (toolbarContainer && !newOptions.showToolbar) { + toolbarContainer.remove(); + } + + if (newOptions.showToolbar && shadowRoot) { + toolbarContainer = createToolbar(shadowRoot); + } + } +}; + +export const getOptions = () => ReactScanInternals.options; + +export const reportRender = (fiber: Fiber, renders: Array) => { + const reportFiber = fiber; + const { selfTime } = getTimings(fiber); + const displayName = getDisplayName(fiber.type); + + // Get data from both current and alternate fibers + const currentData = Store.reportData.get(reportFiber); + const alternateData = fiber.alternate + ? Store.reportData.get(fiber.alternate) + : null; + + // More efficient null checks and Math.max + const existingCount = Math.max( + currentData?.count ?? 0, + alternateData?.count ?? 0, + ); + + // Create single shared object for both fibers + const fiberData: RenderData = { + count: existingCount + renders.length, + time: selfTime || 0, + renders, + displayName, + type: getType(fiber.type) || null, + }; + + // Store in both fibers + Store.reportData.set(reportFiber, fiberData); + if (fiber.alternate) { + Store.reportData.set(fiber.alternate, fiberData); + } + + if (displayName && ReactScanInternals.options.value.report) { + const existingLegacyData = Store.legacyReportData.get(displayName) ?? { + count: 0, + time: 0, + renders: [], + displayName: null, + type: getType(fiber.type) || fiber.type, + }; + + existingLegacyData.count = existingLegacyData.count + renders.length; + existingLegacyData.time = existingLegacyData.time + (selfTime || 0); + existingLegacyData.renders = renders; + + Store.legacyReportData.set(displayName, existingLegacyData); + } + + Store.lastReportTime.value = Date.now(); +}; + +export const isValidFiber = (fiber: Fiber) => { + if (ignoredProps.has(fiber.memoizedProps)) { + return false; + } + + const allowList = ReactScanInternals.componentAllowList; + const shouldAllow = + allowList?.has(fiber.type) ?? allowList?.has(fiber.elementType); + + if (shouldAllow) { + const parent = traverseFiber( + fiber, + (node) => { + const options = + allowList?.get(node.type) ?? allowList?.get(node.elementType); + return options?.includeChildren; + }, + true, + ); + if (!parent && !shouldAllow) return false; + } + return true; +}; +// we only need to run this check once and will read the value in hot path +let isProduction: boolean | null = null; +let rdtHook: ReturnType; +export const getIsProduction = () => { + if (isProduction !== null) { + return isProduction; + } + rdtHook ??= getRDTHook(); + for (const renderer of rdtHook.renderers.values()) { + const buildType = detectReactBuildType(renderer); + if (buildType === 'production') { + isProduction = true; + } + } + return isProduction; +}; + +export const start = () => { + if (typeof window === 'undefined') return; + + if ( + getIsProduction() && + !ReactScanInternals.options.value.dangerouslyForceRunInProduction + ) { + return; + } + + const localStorageOptions = + readLocalStorage('react-scan-options'); + + if (localStorageOptions) { + const { enabled, playSound } = localStorageOptions; + const validLocalOptions = validateOptions({ enabled, playSound }); + if (Object.keys(validLocalOptions).length > 0) { + ReactScanInternals.options.value = { + ...ReactScanInternals.options.value, + ...validLocalOptions, + }; + } + } + + const instrumentation = createInstrumentation('devtools', { + onActive() { + const existingRoot = document.querySelector('react-scan-root'); + if (existingRoot) { + return; + } + + // Create audio context on first user interaction + const createAudioContextOnInteraction = () => { + audioContext = new ( + window.AudioContext || + // @ts-expect-error -- This is a fallback for Safari + window.webkitAudioContext + )(); + + void audioContext.resume(); + }; + + window.addEventListener('pointerdown', createAudioContextOnInteraction, { + once: true, + }); + + const container = document.createElement('div'); + container.id = 'react-scan-root'; + + shadowRoot = container.attachShadow({ mode: 'open' }); + + const fragment = document.createDocumentFragment(); + + const cssStyles = document.createElement('style'); + cssStyles.textContent = styles; + + const iconSprite = new DOMParser().parseFromString( + svgSprite, + 'image/svg+xml', + ).documentElement; + shadowRoot.appendChild(iconSprite); + + const root = document.createElement('div'); + root.id = 'react-scan-toolbar-root'; + root.className = 'absolute z-2147483647'; + + fragment.appendChild(cssStyles); + fragment.appendChild(root); + + shadowRoot.appendChild(fragment); + + document.documentElement.appendChild(container); + + initReactScanOverlay(); + + globalThis.__REACT_SCAN__ = { + ReactScanInternals, + }; + + if (ReactScanInternals.options.value.showToolbar) { + toolbarContainer = createToolbar(shadowRoot); + } + + logIntro(); + }, + onCommitStart() { + ReactScanInternals.options.value.onCommitStart?.(); + }, + onError(error) { + // eslint-disable-next-line no-console + console.error('[React Scan] Error instrumenting:', error); + }, + isValidFiber, + onRender(fiber, renders) { + // todo: don't track renders at all if paused, reduce overhead + if ( + Boolean( + ReactScanInternals.instrumentation?.isPaused.value && + (Store.inspectState.value.kind === 'inspect-off' || + Store.inspectState.value.kind === 'uninitialized'), + ) || + document.visibilityState !== 'visible' + ) { + // don't draw if it's paused or tab is not active + return; + } + + updateFiberRenderData(fiber, renders); + if (ReactScanInternals.options.value.log) { + // this can be expensive given enough re-renders + log(renders); + } + + if (isCompositeFiber(fiber)) { + // report render has a non trivial cost because it calls Date.now(), so we want to avoid the computation if possible + if ( + ReactScanInternals.options.value.showToolbar !== false && + Store.inspectState.value.kind === 'focused' + ) { + reportRender(fiber, renders); + } + } + + if (ReactScanInternals.options.value.log) { + renders; + } + + ReactScanInternals.options.value.onRender?.(fiber, renders); + for (let i = 0, len = renders.length; i < len; i++) { + const render = renders[i]; + + if (ReactScanInternals.options.value.playSound && audioContext) { + const renderTimeThreshold = 10; + const amplitude = Math.min( + 1, + ((render.time ?? 0) - renderTimeThreshold) / + (renderTimeThreshold * 2), + ); + playGeigerClickSound(audioContext, amplitude); + } + } + }, + onCommitFinish() { + ReactScanInternals.options.value.onCommitFinish?.(); + }, + trackChanges: true, + }); + + ReactScanInternals.instrumentation = instrumentation; + + // TODO: add an visual error indicator that it didn't load + const isUsedInBrowserExtension = typeof window !== 'undefined'; + if (!Store.monitor.value && !isUsedInBrowserExtension) { + setTimeout(() => { + if (isInstrumentationActive()) return; + // eslint-disable-next-line no-console + console.error( + '[React Scan] Failed to load. Must import React Scan before React runs.', + ); + }, 5000); + } +}; + +export const withScan = ( + component: React.ComponentType, + options: Options = {}, +) => { + setOptions(options); + const isInIframe = Store.isInIframe.value; + const componentAllowList = ReactScanInternals.componentAllowList; + if (isInIframe || (options.enabled === false && options.showToolbar !== true)) + return component; + if (!componentAllowList) { + ReactScanInternals.componentAllowList = new WeakMap< + React.ComponentType, + Options + >(); + } + if (componentAllowList) { + componentAllowList.set(component, { ...options }); + } + + start(); + + return component; +}; + +export const scan = (options: Options = {}) => { + setOptions(options); + const isInIframe = Store.isInIframe.value; + if (isInIframe || (options.enabled === false && options.showToolbar !== true)) + return; + + start(); +}; + +export const useScan = (options: Options = {}) => { + setOptions(options); + start(); +}; + +export const onRender = ( + type: unknown, + _onRender: (fiber: Fiber, renders: Array) => void, +) => { + const prevOnRender = ReactScanInternals.onRender; + ReactScanInternals.onRender = (fiber, renders) => { + prevOnRender?.(fiber, renders); + if (getType(fiber.type) === type) { + _onRender(fiber, renders); + } + }; +}; + +export const ignoredProps = new WeakSet< + Exclude< + React.ReactNode, + undefined | null | string | number | boolean | bigint + > +>(); + +export const ignoreScan = (node: React.ReactNode) => { + if (typeof node === 'object' && node) { + ignoredProps.add(node); + } +}; diff --git a/packages/scan/src/core/instrumentation.ts b/packages/scan/src/core/instrumentation.ts new file mode 100644 index 0000000..343512b --- /dev/null +++ b/packages/scan/src/core/instrumentation.ts @@ -0,0 +1,455 @@ +import { type Signal, signal } from '@preact/signals'; +import { + getDisplayName, + traverseState, + traverseContexts, + didFiberCommit, + getMutatedHostFibers, + traverseProps, + createFiberVisitor, + getType, + getTimings, + hasMemoCache, + instrument, + type Fiber, + type FiberRoot, +} from 'bippy'; +import { isValidElement } from 'preact'; +import { getChangedPropsDetailed } from '~web/components/inspector/utils'; +import { isEqual } from '~core/utils'; +import { ReactScanInternals, Store, getIsProduction } from './index'; + +let fps = 0; +let lastTime = performance.now(); +let frameCount = 0; +let initedFps = false; + +const updateFPS = () => { + frameCount++; + const now = performance.now(); + if (now - lastTime >= 1000) { + fps = frameCount; + frameCount = 0; + lastTime = now; + } + requestAnimationFrame(updateFPS); +}; + +export const getFPS = () => { + if (!initedFps) { + initedFps = true; + updateFPS(); + fps = 60; + } + + return fps; +}; + +export const isElementVisible = (el: Element) => { + const style = window.getComputedStyle(el); + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + (style as unknown as CSSStyleDeclaration).contentVisibility !== 'hidden' && + style.opacity !== '0' + ); +}; + +export const isValueUnstable = (prevValue: unknown, nextValue: unknown) => { + const prevValueString = fastSerialize(prevValue); + const nextValueString = fastSerialize(nextValue); + return ( + prevValueString === nextValueString && + unstableTypes.includes(typeof prevValue) && + unstableTypes.includes(typeof nextValue) + ); +}; + +export const isElementInViewport = ( + el: Element, + rect = el.getBoundingClientRect(), +) => { + const isVisible = + rect.bottom > 0 && + rect.right > 0 && + rect.top < window.innerHeight && + rect.left < window.innerWidth; + + return isVisible && rect.width && rect.height; +}; + +export interface RenderChange { + type: 'props' | 'state' | 'context'; + name: string; + value: unknown; + prevValue?: unknown; + nextValue?: unknown; + unstable?: boolean; + count?: number; +} + +export interface AggregatedChange { + type: Set<'props' | 'state' | 'context'>; + unstable: boolean; +} + +export interface Render { + phase: 'mount' | 'update' | 'unmount'; + componentName: string | null; + time: number | null; + count: number; + forget: boolean; + changes: Array; + unnecessary: boolean | null; + didCommit: boolean; + fps: number; +} + +const unstableTypes = ['function', 'object']; + +const cache = new WeakMap(); + +export function fastSerialize(value: unknown, depth = 0): string { + if (depth < 0) return '…'; + + switch (typeof value) { + case 'function': + return value.toString(); + case 'string': + return value; + case 'number': + case 'boolean': + case 'undefined': + return String(value); + case 'object': + break; + default: + return String(value); + } + + if (value === null) return 'null'; + + if (cache.has(value)) { + const cached = cache.get(value); + if (cached === undefined) { + throw new Error('Cached value was undefined'); + } + return cached; + } + + if (Array.isArray(value)) { + const str = value.length ? `[${value.length}]` : '[]'; + cache.set(value, str); + return str; + } + + if (isValidElement(value)) { + const type = getDisplayName(value.type) ?? ''; + const propCount = value.props ? Object.keys(value.props).length : 0; + const str = `<${type} ${propCount}>`; + cache.set(value, str); + return str; + } + + if (Object.getPrototypeOf(value) === Object.prototype) { + const keys = Object.keys(value); + const str = keys.length ? `{${keys.length}}` : '{}'; + cache.set(value, str); + return str; + } + + const ctor = (value as unknown).constructor; + if (ctor && typeof ctor === 'function' && ctor.name) { + const str = `${ctor.name}{…}`; + cache.set(value, str); + return str; + } + + const tagString = Object.prototype.toString.call(value).slice(8, -1); + const str = `${tagString}{…}`; + cache.set(value, str); + return str; +} + +export const getPropsChanges = (fiber: Fiber) => { + const changes: Array = []; + + const prevProps = fiber.alternate?.memoizedProps || {}; + const nextProps = fiber.memoizedProps || {}; + + const allKeys = new Set([ + ...Object.keys(prevProps), + ...Object.keys(nextProps), + ]); + for (const propName in allKeys) { + const prevValue = prevProps?.[propName]; + const nextValue = nextProps?.[propName]; + + if ( + isEqual(prevValue, nextValue) || + isValidElement(prevValue) || + isValidElement(nextValue) + ) { + continue; + } + const change: RenderChange = { + type: 'props', + name: propName, + value: nextValue, + unstable: false, + }; + changes.push(change); + + if (isValueUnstable(prevValue, nextValue)) { + change.unstable = true; + } + } + + return changes; +}; + +export const getStateChanges = (fiber: Fiber) => { + const changes: Array = []; + + traverseState(fiber, (prevState, nextState) => { + if (isEqual(prevState.memoizedState, nextState.memoizedState)) return; + const change: RenderChange = { + type: 'state', + name: '', // bad interface should make this a discriminated union + value: nextState.memoizedState, + unstable: false, + }; + changes.push(change); + }); + + return changes; +}; + +export const getContextChanges = (fiber: Fiber) => { + const changes: Array = []; + + traverseContexts(fiber, (prevContext, nextContext) => { + const prevValue = prevContext.memoizedValue; + const nextValue = nextContext.memoizedValue; + + const change: RenderChange = { + type: 'context', + name: '', + value: nextValue, + unstable: false, + }; + changes.push(change); + + const prevValueString = fastSerialize(prevValue); + const nextValueString = fastSerialize(nextValue); + + if ( + unstableTypes.includes(typeof prevValue) && + unstableTypes.includes(typeof nextValue) && + prevValueString === nextValueString + ) { + change.unstable = true; + } + }); + + return changes; +}; + +type OnRenderHandler = (fiber: Fiber, renders: Array) => void; +type OnCommitStartHandler = () => void; +type OnCommitFinishHandler = () => void; +type OnErrorHandler = (error: unknown) => void; +type IsValidFiberHandler = (fiber: Fiber) => boolean; +type OnActiveHandler = () => void; + +interface InstrumentationConfig { + onCommitStart: OnCommitStartHandler; + isValidFiber: IsValidFiberHandler; + onRender: OnRenderHandler; + onCommitFinish: OnCommitFinishHandler; + onError: OnErrorHandler; + onActive?: OnActiveHandler; + // monitoring does not need to track changes, and it adds overhead to leave it on + trackChanges: boolean; + // allows monitoring to continue tracking renders even if react scan dev mode is disabled + forceAlwaysTrackRenders?: boolean; +} + +interface InstrumentationInstance { + key: string; + config: InstrumentationConfig; + instrumentation: Instrumentation; +} + +interface Instrumentation { + isPaused: Signal; + fiberRoots: Set; +} + +const instrumentationInstances = new Map(); +let inited = false; + +const getAllInstances = () => Array.from(instrumentationInstances.values()); + +// FIXME: calculation is slow +export const isRenderUnnecessary = (fiber: Fiber) => { + if (!didFiberCommit(fiber)) return true; + + const mutatedHostFibers = getMutatedHostFibers(fiber); + for (const mutatedHostFiber of mutatedHostFibers) { + let isRequiredChange = false; + traverseProps(mutatedHostFiber, (prevValue, nextValue) => { + if ( + !isEqual(prevValue, nextValue) && + !isValueUnstable(prevValue, nextValue) + ) { + isRequiredChange = true; + } + }); + if (isRequiredChange) return false; + } + return true; +}; + +const shouldRunUnnecessaryRenderCheck = () => { + // yes, this can be condensed into one conditional, but ifs are easier to reason/build on than long boolean expressions + if (!ReactScanInternals.options.value.trackUnnecessaryRenders) { + return false; + } + + // only run unnecessaryRenderCheck when monitoring is active in production if the user set dangerouslyForceRunInProduction + if ( + getIsProduction() && + Store.monitor.value && + ReactScanInternals.options.value.dangerouslyForceRunInProduction && + ReactScanInternals.options.value.trackUnnecessaryRenders + ) { + return true; + } + + if (getIsProduction() && Store.monitor.value) { + return false; + } + + return ReactScanInternals.options.value.trackUnnecessaryRenders; +}; + +export const createInstrumentation = ( + instanceKey: string, + config: InstrumentationConfig, +) => { + const instrumentation: Instrumentation = { + // this will typically be false, but in cases where a user provides showToolbar: true, this will be true + isPaused: signal(!ReactScanInternals.options.value.enabled), + fiberRoots: new Set(), + }; + instrumentationInstances.set(instanceKey, { + key: instanceKey, + config, + instrumentation, + }); + if (!inited) { + inited = true; + const visitor = createFiberVisitor({ + onRender(fiber, phase) { + const type = getType(fiber.type); + if (!type) return null; + + const allInstances = getAllInstances(); + const validInstancesIndicies: Array = []; + for (let i = 0, len = allInstances.length; i < len; i++) { + const instance = allInstances[i]; + if (!instance.config.isValidFiber(fiber)) continue; + validInstancesIndicies.push(i); + } + if (!validInstancesIndicies.length) return null; + + const changes: Array = []; + + const propsChanges = getChangedPropsDetailed(fiber).map((change) => ({ + type: 'props' as const, + name: change.name, + value: change.value, + prevValue: change.prevValue, + unstable: false, + })); + + const stateChanges = getStateChanges(fiber).map((change) => ({ + type: 'state' as const, + name: change.name, + value: change.value, + prevValue: change.prevValue, + count: change.count, + unstable: false, + })); + + const contextChanges = getContextChanges(fiber).map((change) => ({ + type: 'context' as const, + name: change.name, + value: change.value, + prevValue: change.prevValue, + count: change.count, + unstable: false, + })); + + changes.push(...propsChanges, ...stateChanges, ...contextChanges); + + const { selfTime } = getTimings(fiber); + + const fps = getFPS(); + + const render: Render = { + phase, + componentName: getDisplayName(type), + count: 1, + changes, + time: selfTime, + forget: hasMemoCache(fiber), + // todo: allow this to be toggle-able through toolbar + // todo: performance optimization: if the last fiber measure was very off screen, do not run isRenderUnnecessary + unnecessary: shouldRunUnnecessaryRenderCheck() + ? isRenderUnnecessary(fiber) + : null, + + didCommit: didFiberCommit(fiber), + fps, + }; + + for (let i = 0, len = validInstancesIndicies.length; i < len; i++) { + const index = validInstancesIndicies[i]; + const instance = allInstances[index]; + instance.config.onRender(fiber, [render]); + } + }, + onError(error) { + const allInstances = getAllInstances(); + for (const instance of allInstances) { + instance.config.onError(error); + } + }, + }); + instrument({ + name: 'react-scan', + onActive: config.onActive, + onCommitFiberRoot(rendererID, root) { + if ( + ReactScanInternals.instrumentation?.isPaused.value && + (Store.inspectState.value.kind === 'inspect-off' || + Store.inspectState.value.kind === 'uninitialized') && + !config.forceAlwaysTrackRenders + ) { + return; + } + const allInstances = getAllInstances(); + for (const instance of allInstances) { + instance.config.onCommitStart(); + } + visitor(rendererID, root); + for (const instance of allInstances) { + instance.config.onCommitFinish(); + } + }, + }); + } + return instrumentation; +}; diff --git a/packages/scan/src/core/monitor/constants.ts b/packages/scan/src/core/monitor/constants.ts new file mode 100644 index 0000000..ef51eec --- /dev/null +++ b/packages/scan/src/core/monitor/constants.ts @@ -0,0 +1,60 @@ +/** + * We do prototype caching for highly performant code, do not put browser specific code here without a guard. + * + * _{global} is also a hack that reduces the size of the bundle + * + * Examples: + * @see https://github.com/ged-odoo/blockdom/blob/5849f0887ff8dc7f3f173f870ed850a89946fcfd/src/block_compiler.ts#L9 + * @see https://github.com/localvoid/ivi/blob/bd5bbe8c6b39a7be1051c16ea0a07b3df9a178bd/packages/ivi/src/client/core.ts#L13 + */ + +/* eslint-disable prefer-const */ +/* eslint-disable import/no-mutable-exports */ + +/** + * Do not destructure exports or import React from "react" here. + * From empirical ad-hoc testing, this breaks in certain scenarios. + */ +import * as React from 'react'; + +/** + * useRef will be undefined in "use server" + * + * @see https://nextjs.org/docs/messages/react-client-hook-in-server-component + */ +export const isRSC = () => !React.useRef; +export const isSSR = () => typeof window === 'undefined' || isRSC(); + +interface WindowWithCypress extends Window { + Cypress?: unknown; +} + +export const isTest = + (typeof window !== 'undefined' && + /** + * @see https://docs.cypress.io/faq/questions/using-cypress-faq#Is-there-any-way-to-detect-if-my-app-is-running-under-Cypress + */ + ((window as WindowWithCypress).Cypress || + /** + * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/webdriver + */ + navigator.webdriver)) || + /** + * @see https://stackoverflow.com/a/60491322 + */ + // @ts-expect-error jest is a global in test + typeof jest !== 'undefined'; + +export const VERSION = null; // todo +export const PAYLOAD_VERSION = null; // todo + +export const MAX_QUEUE_SIZE = 300; +export const FLUSH_TIMEOUT = isTest + ? 100 // Make sure there is no data loss in tests + : process.env.NODE_ENV === 'production' + ? 5000 + : 1000; +export const SESSION_EXPIRE_TIMEOUT = 300000; // 5 minutes +export const GZIP_MIN_LEN = 1000; +export const GZIP_MAX_LEN = 60000; // 1 minute +export const MAX_PENDING_REQUESTS = 15; diff --git a/packages/scan/src/core/monitor/index.ts b/packages/scan/src/core/monitor/index.ts new file mode 100644 index 0000000..f92e994 --- /dev/null +++ b/packages/scan/src/core/monitor/index.ts @@ -0,0 +1,187 @@ +'use client'; +import { + getDisplayName, + getTimings, + isCompositeFiber, + type Fiber, +} from 'bippy'; +import { useEffect } from 'react'; +import { + type MonitoringOptions, + ReactScanInternals, + setOptions, + Store, +} from '..'; +import { createInstrumentation, type Render } from '../instrumentation'; +import { updateFiberRenderData } from '../utils'; +import { initPerformanceMonitoring } from './performance'; +import { getSession } from './utils'; +import { flush } from './network'; +import { computeRoute } from './params/utils'; + +// max retries before the set of components do not get reported (avoid memory leaks of the set of fibers stored on the component aggregation) +const MAX_RETRIES_BEFORE_COMPONENT_GC = 7; + +export interface MonitoringProps { + url?: string; + apiKey: string; + + // For Session and Interaction + path?: string | null; // pathname (i.e /foo/2/bar/3) + route?: string | null; // computed from path and params (i.e /foo/:fooId/bar/:barId) + + // Only used / should be provided to compute the route when using Monitoring without supported framework + params?: Record; + + // Tracking regressions across commits and branches + commit?: string | null; + branch?: string | null; +} + +export type MonitoringWithoutRouteProps = Omit< + MonitoringProps, + 'route' | 'path' +>; + +export const Monitoring = ({ + url, + apiKey, + params, + path = null, // path passed down would be reactive + route = null, + commit = null, + branch = null, +}: MonitoringProps) => { + if (!apiKey) + throw new Error('Please provide a valid API key for React Scan monitoring'); + url ??= 'https://monitoring.react-scan.com/api/v1/ingest'; + + Store.monitor.value ??= { + pendingRequests: 0, + interactions: [], + session: getSession({ commit, branch }).catch(() => null), + url, + apiKey, + route, + commit, + branch, + }; + + // When using Monitoring without framework, we need to compute the route from the path and params + if (!route && path && params) { + Store.monitor.value.route = computeRoute(path, params); + } else if (typeof window !== 'undefined') { + Store.monitor.value.route = + route ?? path ?? new URL(window.location.toString()).pathname; // this is inaccurate on vanilla react if the path is not provided but used for session route + } + + useEffect(() => { + scanMonitoring({ enabled: true }); + return initPerformanceMonitoring(); + }, []); + + return null; +}; + +export const scanMonitoring = (options: MonitoringOptions) => { + setOptions(options); + startMonitoring(); +}; + +let flushInterval: ReturnType; + +export const startMonitoring = () => { + if (!Store.monitor.value) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + 'Invariant: startMonitoring can never be called when monitoring is not initialized', + ); + } + } + + if (flushInterval) { + clearInterval(flushInterval); + } + + flushInterval = setInterval(() => { + try { + void flush(); + } catch { + /* */ + } + }, 2000); + + globalThis.__REACT_SCAN__ = { + ReactScanInternals, + }; + const instrumentation = createInstrumentation('monitoring', { + onCommitStart() { + ReactScanInternals.options.value.onCommitStart?.(); + }, + onError() { + // todo: report to server? + }, + isValidFiber() { + return true; + }, + onRender(fiber, renders) { + updateFiberRenderData(fiber, renders); + + if (isCompositeFiber(fiber)) { + aggregateComponentRenderToInteraction(fiber, renders); + } + ReactScanInternals.options.value.onRender?.(fiber, renders); + }, + onCommitFinish() { + ReactScanInternals.options.value.onCommitFinish?.(); + }, + trackChanges: false, + forceAlwaysTrackRenders: true, + }); + + ReactScanInternals.instrumentation = instrumentation; +}; + +const aggregateComponentRenderToInteraction = ( + fiber: Fiber, + renders: Array, +) => { + const monitor = Store.monitor.value; + if (!monitor || !monitor.interactions || monitor.interactions.length === 0) + return; + const lastInteraction = monitor.interactions.at(-1); // Associate component render with last interaction + if (!lastInteraction) return; + + const displayName = getDisplayName(fiber.type); + if (!displayName) return; // TODO(nisarg): it may be useful to somehow report the first ancestor with a display name instead of completely ignoring + + let component = lastInteraction.components.get(displayName); // TODO(nisarg): Same names are grouped together which is wrong. + + if (!component) { + component = { + fibers: new Set(), + name: displayName, + renders: 0, + retiresAllowed: MAX_RETRIES_BEFORE_COMPONENT_GC, + uniqueInteractionId: lastInteraction.uniqueInteractionId, + }; + lastInteraction.components.set(displayName, component); + } + + if (fiber.alternate && !component.fibers.has(fiber.alternate)) { + // then the alternate tree fiber exists in the weakset, don't double count the instance + component.fibers.add(fiber.alternate); + } + + const rendersCount = renders.length; + component.renders += rendersCount; + + // We leave the times undefined to differentiate between a 0ms render and a non-profiled render. + if (fiber.actualDuration) { + const { selfTime, totalTime } = getTimings(fiber); + if (!component.totalTime) component.totalTime = 0; + if (!component.selfTime) component.selfTime = 0; + component.totalTime += totalTime; + component.selfTime += selfTime; + } +}; diff --git a/packages/scan/src/core/monitor/network.ts b/packages/scan/src/core/monitor/network.ts new file mode 100644 index 0000000..3768c64 --- /dev/null +++ b/packages/scan/src/core/monitor/network.ts @@ -0,0 +1,257 @@ +import { Store } from '../..'; +import { GZIP_MIN_LEN, GZIP_MAX_LEN, MAX_PENDING_REQUESTS } from './constants'; +import { getSession } from './utils'; +import type { + Interaction, + IngestRequest, + InternalInteraction, + Component, +} from './types'; + +const INTERACTION_TIME_TILL_COMPLETED = 4000; + +const truncate = (value: number, decimalPlaces = 4) => + Number(value.toFixed(decimalPlaces)); + +export const flush = async (): Promise => { + const monitor = Store.monitor.value; + if ( + !monitor || + !navigator.onLine || + !monitor.url || + !monitor.interactions.length + ) { + return; + } + const now = performance.now(); + // We might trigger flush before the interaction is completed, + // so we need to split them into pending and completed by an arbitrary time. + const pendingInteractions = new Array(); + const completedInteractions = new Array(); + + const interactions = monitor.interactions; + for (let i = 0; i < interactions.length; i++) { + const interaction = interactions[i]; + const timeSinceStart = now - interaction.performanceEntry.startTime; + // these interactions were retried enough and should be discarded to avoid mem leak + if (timeSinceStart > 30000) { + // Skip this iteration + } else if (timeSinceStart <= INTERACTION_TIME_TILL_COMPLETED) { + pendingInteractions.push(interaction); + } else { + completedInteractions.push(interaction); + } + } + + // nothing to flush + if (!completedInteractions.length) return; + + // idempotent + const session = await getSession({ + commit: monitor.commit, + branch: monitor.branch, + }).catch(() => null); + + if (!session) return; + + const aggregatedComponents = new Array(); + const aggregatedInteractions = new Array(); + for (let i = 0; i < completedInteractions.length; i++) { + const interaction = completedInteractions[i]; + + // META INFORMATION IS FOR DEBUGGING THIS MUST BE REMOVED SOON + const { + duration, + entries, + id, + inputDelay, + latency, + presentationDelay, + processingDuration, + processingEnd, + processingStart, + referrer, + startTime, + timeOrigin, + timeSinceTabInactive, + timestamp, + type, + visibilityState, + } = interaction.performanceEntry; + aggregatedInteractions.push({ + id: i, + path: interaction.componentPath, + name: interaction.componentName, + time: truncate(duration), + timestamp, + type, + // fixme: we can aggregate around url|route|commit|branch better to compress payload + url: interaction.url, + route: interaction.route, + commit: interaction.commit, + branch: interaction.branch, + uniqueInteractionId: interaction.uniqueInteractionId, + meta: { + performanceEntry: { + id, + inputDelay: truncate(inputDelay), + latency: truncate(latency), + presentationDelay: truncate(presentationDelay), + processingDuration: truncate(processingDuration), + processingEnd, + processingStart, + referrer, + startTime, + timeOrigin, + timeSinceTabInactive, + visibilityState, + duration: truncate(duration), + entries: entries.map((entry) => { + const { + duration, + entryType, + interactionId, + name, + processingEnd, + processingStart, + startTime, + } = entry; + return { + duration: truncate(duration), + entryType, + interactionId, + name, + processingEnd, + processingStart, + startTime, + }; + }), + }, + }, + }); + + const components = Array.from(interaction.components.entries()); + for (let j = 0; j < components.length; j++) { + const [name, component] = components[j]; + aggregatedComponents.push({ + name, + instances: component.fibers.size, + interactionId: i, + renders: component.renders, + selfTime: + typeof component.selfTime === 'number' + ? truncate(component.selfTime) + : component.selfTime, + totalTime: + typeof component.totalTime === 'number' + ? truncate(component.totalTime) + : component.totalTime, + }); + } + } + + const payload: IngestRequest = { + interactions: aggregatedInteractions, + components: aggregatedComponents, + session: { + ...session, + url: window.location.toString(), + route: monitor.route, // this might be inaccurate but used to caculate which paths all the unique sessions are coming from without having to join on the interactions table (expensive) + }, + }; + + monitor.pendingRequests++; + monitor.interactions = pendingInteractions; + try { + transport(monitor.url, payload) + .then(() => { + monitor.pendingRequests--; + // there may still be renders associated with these interaction, so don't flush just yet + }) + .catch(async () => { + // we let the next interval handle retrying, instead of explicitly retrying + monitor.interactions = monitor.interactions.concat( + completedInteractions, + ); + }); + } catch { + /* */ + } + + // Keep only recent interactions + monitor.interactions = pendingInteractions; +}; + +const CONTENT_TYPE = 'application/json'; +const supportsCompression = typeof CompressionStream === 'function'; + +export const compress = async (payload: string): Promise => { + const stream = new Blob([payload], { type: CONTENT_TYPE }) + .stream() + .pipeThrough(new CompressionStream('gzip')); + return new Response(stream).arrayBuffer(); +}; + +/** + * Modified from @palette.dev/browser: + * + * @see https://gist.github.com/aidenybai/473689493f2d5d01bbc52e2da5950b45#file-palette-dev-browser-dist-palette-dev-mjs-L365 + */ +interface RequestHeaders extends Record { + 'Content-Type': string; + 'Content-Encoding'?: string; + 'x-api-key'?: string; +} + +export const transport = async ( + initialUrl: string, + payload: IngestRequest, +): Promise<{ ok: boolean }> => { + const fail = { ok: false }; + const json = JSON.stringify(payload); + // gzip may not be worth it for small payloads, + // only use it if the payload is large enough + const shouldCompress = json.length > GZIP_MIN_LEN; + const body = + shouldCompress && supportsCompression ? await compress(json) : json; + + if (!navigator.onLine) return fail; + const headers: RequestHeaders = { + 'Content-Type': CONTENT_TYPE, + 'Content-Encoding': shouldCompress ? 'gzip' : undefined, + 'x-api-key': Store.monitor.value?.apiKey, + }; + let url = initialUrl; + if (shouldCompress) url += '?z=1'; + const size = typeof body === 'string' ? body.length : body.byteLength; + + return fetch(url, { + body, + method: 'POST', + referrerPolicy: 'origin', + /** + * Outgoing requests are usually cancelled when navigating to a different page, causing a "TypeError: Failed to + * fetch" error and sending a "network_error" client-outcome - in Chrome, the request status shows "(cancelled)". + * The `keepalive` flag keeps outgoing requests alive, even when switching pages. We want this since we're + * frequently sending events right before the user is switching pages (e.g., when finishing navigation transactions). + * + * This is the modern alternative to the navigator.sendBeacon API. + * @see https://javascript.info/fetch-api#keepalive + * + * Gotchas: + * - `keepalive` isn't supported by Firefox + * - As per spec (https://fetch.spec.whatwg.org/#http-network-or-cache-fetch): + * If the sum of contentLength and inflightKeepaliveBytes is greater than 64 kibibytes, then return a network error. + * We will therefore only activate the flag when we're below that limit. + * - There is also a limit of requests that can be open at the same time, so we also limit this to 15. + * + * @see https://github.com/getsentry/sentry-javascript/pull/7553 + */ + keepalive: + GZIP_MAX_LEN > size && + MAX_PENDING_REQUESTS > (Store.monitor.value?.pendingRequests ?? 0), + priority: 'low', + // mode: 'no-cors', + headers, + }); +}; diff --git a/packages/scan/src/core/monitor/params/astro/Monitoring.astro b/packages/scan/src/core/monitor/params/astro/Monitoring.astro new file mode 100644 index 0000000..91e89ac --- /dev/null +++ b/packages/scan/src/core/monitor/params/astro/Monitoring.astro @@ -0,0 +1,12 @@ +--- +import type { MonitoringWithoutRouteProps } from '../..'; +// @ts-ignore This file will not be packaged, so the file to be imported should be a .mjs file. +import { AstroMonitor } from './component.mjs'; + +type Props = MonitoringWithoutRouteProps; + +const path = Astro.url.pathname; +const params = Astro.params; +--- + + diff --git a/packages/scan/src/core/monitor/params/astro/component.ts b/packages/scan/src/core/monitor/params/astro/component.ts new file mode 100644 index 0000000..c7c7910 --- /dev/null +++ b/packages/scan/src/core/monitor/params/astro/component.ts @@ -0,0 +1,22 @@ +import { createElement } from 'react'; +import { + Monitoring as BaseMonitoring, + type MonitoringWithoutRouteProps, +} from '../..'; +import { computeRoute } from '../utils'; + +export function AstroMonitor( + props: { + path: string; + params: Record; + } & MonitoringWithoutRouteProps, +) { + const path = props.path; + const route = computeRoute(path, props.params); + + return createElement(BaseMonitoring, { + ...props, + route, + path, + }); +} diff --git a/packages/scan/src/core/monitor/params/astro/index.ts b/packages/scan/src/core/monitor/params/astro/index.ts new file mode 100644 index 0000000..7a07d9e --- /dev/null +++ b/packages/scan/src/core/monitor/params/astro/index.ts @@ -0,0 +1,3 @@ +// This file will not be packaged + +export { default as Monitoring } from './Monitoring.astro'; diff --git a/packages/scan/src/core/monitor/params/next.ts b/packages/scan/src/core/monitor/params/next.ts new file mode 100644 index 0000000..475f570 --- /dev/null +++ b/packages/scan/src/core/monitor/params/next.ts @@ -0,0 +1,66 @@ +'use client'; + +import { useParams, usePathname, useSearchParams } from 'next/navigation.js'; +import { createElement, Suspense } from 'react'; +import { + Monitoring as BaseMonitoring, + type MonitoringWithoutRouteProps, +} from '..'; +import { computeRoute } from './utils'; + +/** + * This hook works in both Next.js Pages and App Router: + * - App Router: Uses the new useParams() hook directly + * - Pages Router: useParams() returns empty object, falls back to searchParams + * This fallback behavior ensures compatibility across both routing systems + */ +const useRoute = (): { + route: string | null; + path: string; +} => { + const params = useParams(); + const searchParams = useSearchParams(); + const path = usePathname(); + + // Until we have route parameters, we don't compute the route + if (!params) { + return { route: null, path }; + } + // in Next.js@13, useParams() could return an empty object for pages router, and we default to searchParams. + const finalParams = Object.keys(params).length + ? (params as Record>) + : Object.fromEntries(searchParams.entries()); + return { route: computeRoute(path, finalParams), path }; +}; +export function MonitoringInner(props: MonitoringWithoutRouteProps) { + const { route, path } = useRoute(); + + // we need to fix build so this doesn't get compiled to preact jsx + return createElement(BaseMonitoring, { + ...props, + route, + path, + }); +} + +/** + * The double 'use client' directive pattern is intentional: + * 1. Top-level directive marks the entire module as client-side + * 2. IIFE-wrapped component with its own directive ensures: + * - Component is properly tree-shaken (via @__PURE__) + * - Component maintains client context when code-split + * - Execution scope is preserved + * + * This pattern is particularly important for Next.js's module + * system and its handling of Server/Client Components. + */ +export const Monitoring = /* @__PURE__ */ (() => { + 'use client'; + return function Monitoring(props: MonitoringWithoutRouteProps) { + return createElement( + Suspense, + { fallback: null }, + createElement(MonitoringInner, props), + ); + }; +})(); diff --git a/packages/scan/src/core/monitor/params/react-router-v5.ts b/packages/scan/src/core/monitor/params/react-router-v5.ts new file mode 100644 index 0000000..71b8120 --- /dev/null +++ b/packages/scan/src/core/monitor/params/react-router-v5.ts @@ -0,0 +1,31 @@ +import { createElement } from 'react'; +import { useRouteMatch, useLocation } from 'react-router'; +import { Monitoring as BaseMonitoring, type MonitoringWithoutRouteProps} from '..'; +import { computeRoute } from './utils'; +import type { RouteInfo } from './types'; + +const useRoute = (): RouteInfo => { + const match = useRouteMatch(); + const { pathname } = useLocation(); + const params = match?.params || {}; + + if (!params) { + return { route: null, path: pathname }; + } + + return { + route: computeRoute(pathname, params), + path: pathname, + }; +}; + +function ReactRouterV5Monitor(props: MonitoringWithoutRouteProps) { + const { route, path } = useRoute(); + return createElement(BaseMonitoring, { + ...props, + route, + path, + }); +} + +export { ReactRouterV5Monitor as Monitoring }; diff --git a/packages/scan/src/core/monitor/params/react-router-v6.ts b/packages/scan/src/core/monitor/params/react-router-v6.ts new file mode 100644 index 0000000..d62f12f --- /dev/null +++ b/packages/scan/src/core/monitor/params/react-router-v6.ts @@ -0,0 +1,34 @@ +import { createElement } from 'react'; +import { useParams, useLocation } from 'react-router'; +import { Monitoring as BaseMonitoring, type MonitoringWithoutRouteProps } from '..'; +import { computeReactRouterRoute } from './utils'; +import type { RouteInfo } from './types'; + +const useRoute = (): RouteInfo => { + const params = useParams(); + const { pathname } = useLocation(); + + if (!params || Object.keys(params).length === 0) { + return { route: null, path: pathname }; + } + + const validParams = Object.fromEntries( + Object.entries(params).filter(([_, v]) => v !== undefined), + ) as Record>; + + return { + route: computeReactRouterRoute(pathname, validParams), + path: pathname, + }; +}; + +function ReactRouterMonitor(props: MonitoringWithoutRouteProps) { + const { route, path } = useRoute(); + return createElement(BaseMonitoring, { + ...props, + route, + path, + }); +} + +export { ReactRouterMonitor as Monitoring }; diff --git a/packages/scan/src/core/monitor/params/remix.ts b/packages/scan/src/core/monitor/params/remix.ts new file mode 100644 index 0000000..e9ff727 --- /dev/null +++ b/packages/scan/src/core/monitor/params/remix.ts @@ -0,0 +1,32 @@ +import { createElement } from 'react'; +import { useParams, useLocation } from '@remix-run/react'; +import { Monitoring as BaseMonitoring, type MonitoringWithoutRouteProps} from '..'; +import { computeReactRouterRoute } from './utils'; +import type { RouteInfo } from './types'; + +const useRoute = (): RouteInfo => { + const params = useParams(); + const { pathname } = useLocation(); + + if (!params || Object.keys(params).length === 0) { + return { route: null, path: pathname }; + } + + const validParams = params as Record; + + return { + route: computeReactRouterRoute(pathname, validParams), + path: pathname, + }; +}; + +function RemixMonitor(props: MonitoringWithoutRouteProps) { + const { route, path } = useRoute(); + return createElement(BaseMonitoring, { + ...props, + route, + path, + }); +} + +export { RemixMonitor as Monitoring }; diff --git a/packages/scan/src/core/monitor/params/types.ts b/packages/scan/src/core/monitor/params/types.ts new file mode 100644 index 0000000..25310a5 --- /dev/null +++ b/packages/scan/src/core/monitor/params/types.ts @@ -0,0 +1,4 @@ +export interface RouteInfo { + route: string | null; + path: string; +} \ No newline at end of file diff --git a/packages/scan/src/core/monitor/params/utils.ts b/packages/scan/src/core/monitor/params/utils.ts new file mode 100644 index 0000000..b3c5f4e --- /dev/null +++ b/packages/scan/src/core/monitor/params/utils.ts @@ -0,0 +1,70 @@ +// adapted from vercel analytics https://github.dev/vercel/analytics +interface DynamicSegmentFormatter { + param: (key: string) => string; + catchAll: (key: string) => string; +} + +function computeRouteWithFormatter( + pathname: string | null, + pathParams: Record> | null, + formatter: DynamicSegmentFormatter, +): string | null { + if (!pathname || !pathParams) { + return pathname; + } + + let result = pathname; + try { + const entries = Object.entries(pathParams); + // simple keys must be handled first + for (const [key, value] of entries) { + if (!Array.isArray(value)) { + const matcher = turnValueToRegExp(value); + if (matcher.test(result)) { + result = result.replace(matcher, formatter.param(key)); + } + } + } + // array values next + for (const [key, value] of entries) { + if (Array.isArray(value)) { + const matcher = turnValueToRegExp(value.join('/')); + if (matcher.test(result)) { + result = result.replace(matcher, formatter.catchAll(key)); + } + } + } + return result; + } catch (e) { + return pathname; + } +} + +// Next.js style routes (default) +export function computeRoute( + pathname: string | null, + pathParams: Record> | null, +): string | null { + return computeRouteWithFormatter(pathname, pathParams, { + param: (key) => `/[${key}]`, + catchAll: (key) => `/[...${key}]`, + }); +} + +export function computeReactRouterRoute( + pathname: string | null, + pathParams: Record> | null, +): string | null { + return computeRouteWithFormatter(pathname, pathParams, { + param: (key) => `/:${key}`, + catchAll: (key) => `/*${key}`, + }); +} + +export function turnValueToRegExp(value: string): RegExp { + return new RegExp(`/${escapeRegExp(value)}(?=[/?#]|$)`); +} + +export function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/scan/src/core/monitor/performance.ts b/packages/scan/src/core/monitor/performance.ts new file mode 100644 index 0000000..40e2adf --- /dev/null +++ b/packages/scan/src/core/monitor/performance.ts @@ -0,0 +1,327 @@ +import { getDisplayName, type Fiber } from 'bippy'; +import { getCompositeComponentFromElement } from '~web/components/inspector/utils'; +import { Store } from '../../'; +import type { + PerformanceInteraction, + PerformanceInteractionEntry, +} from './types'; + +interface PathFilters { + skipProviders: boolean; + skipHocs: boolean; + skipContainers: boolean; + skipMinified: boolean; + skipUtilities: boolean; + skipBoundaries: boolean; +} + +const DEFAULT_FILTERS: PathFilters = { + skipProviders: true, + skipHocs: true, + skipContainers: true, + skipMinified: true, + skipUtilities: true, + skipBoundaries: true, +}; + +const FILTER_PATTERNS = { + providers: [/Provider$/, /^Provider$/, /^Context$/], + hocs: [/^with[A-Z]/, /^forward(?:Ref)?$/i, /^Forward(?:Ref)?\(/], + containers: [/^(?:App)?Container$/, /^Root$/, /^ReactDev/], + utilities: [ + /^Fragment$/, + /^Suspense$/, + /^ErrorBoundary$/, + /^Portal$/, + /^Consumer$/, + /^Layout$/, + /^Router/, + /^Hydration/, + ], + boundaries: [/^Boundary$/, /Boundary$/, /^Provider$/, /Provider$/], +}; + +const shouldIncludeInPath = ( + name: string, + filters: PathFilters = DEFAULT_FILTERS, +): boolean => { + const patternsToCheck: Array = []; + if (filters.skipProviders) patternsToCheck.push(...FILTER_PATTERNS.providers); + if (filters.skipHocs) patternsToCheck.push(...FILTER_PATTERNS.hocs); + if (filters.skipContainers) + patternsToCheck.push(...FILTER_PATTERNS.containers); + if (filters.skipUtilities) patternsToCheck.push(...FILTER_PATTERNS.utilities); + if (filters.skipBoundaries) + patternsToCheck.push(...FILTER_PATTERNS.boundaries); + return !patternsToCheck.some((pattern) => pattern.test(name)); +}; + +const minifiedPatterns = [ + /^[a-z]$/, // Single lowercase letter + /^[a-z][0-9]$/, // Lowercase letter followed by number + /^_+$/, // Just underscores + /^[A-Za-z][_$]$/, // Letter followed by underscore or dollar + /^[a-z]{1,2}$/, // 1-2 lowercase letters +]; + +const isMinified = (name: string): boolean => { + if (!name || typeof name !== 'string') { + return true; + } + + for (let i = 0; i < minifiedPatterns.length; i++) { + if (minifiedPatterns[i].test(name)) return true; + } + + const hasNoVowels = !/[aeiou]/i.test(name); + const hasMostlyNumbers = (name.match(/\d/g)?.length ?? 0) > name.length / 2; + const isSingleWordLowerCase = /^[a-z]+$/.test(name); + const hasRandomLookingChars = /[$_]{2,}/.test(name); + + // If more than 2 of the following are true, we consider the name minified + return ( + Number(hasNoVowels) + + Number(hasMostlyNumbers) + + Number(isSingleWordLowerCase) + + Number(hasRandomLookingChars) >= + 2 + ); +}; + +export const getInteractionPath = ( + initialFiber: Fiber | null, + filters: PathFilters = DEFAULT_FILTERS, +): Array => { + if (!initialFiber) return []; + + const currentName = getDisplayName(initialFiber.type); + if (!currentName) return []; + + const stack = new Array(); + let fiber = initialFiber; + while (fiber.return) { + const name = getCleanComponentName(fiber.type); + if (name && !isMinified(name) && shouldIncludeInPath(name, filters)) { + stack.push(name); + } + fiber = fiber.return; + } + const fullPath = new Array(stack.length); + for (let i = 0; i < stack.length; i++) { + fullPath[i] = stack[stack.length - i - 1]; + } + return fullPath; +}; + +let currentMouseOver: Element; + +interface FiberType { + displayName?: string; + name?: string; + [key: string]: unknown; +} + +const getCleanComponentName = (component: FiberType): string => { + const name = getDisplayName(component); + if (!name) return ''; + + return name.replace( + /^(?:Memo|Forward(?:Ref)?|With.*?)\((?.*?)\)$/, + '$', + ); +}; + +// For future use, normalization of paths happens on server side now using path property of interaction +const _normalizePath = (path: Array): string => { + const cleaned = path.filter(Boolean); + const deduped = cleaned.filter((name, i) => name !== cleaned[i - 1]); + return deduped.join('.'); +}; + +const handleMouseover = (event: Event) => { + if (!(event.target instanceof Element)) return; + currentMouseOver = event.target; +}; + +const getFirstNamedAncestorCompositeFiber = (element: Element) => { + let curr: Element | null = element; + let parentCompositeFiber: Fiber | null = null; + while (!parentCompositeFiber && curr.parentElement) { + curr = curr.parentElement; + + const { parentCompositeFiber: fiber } = + getCompositeComponentFromElement(curr); + + if (!fiber) { + continue; + } + if (getDisplayName(fiber?.type)) { + parentCompositeFiber = fiber; + } + } + return parentCompositeFiber; +}; + +let unsubscribeTrackVisibilityChange: (() => void) | undefined; +// fixme: compress me if this stays here for bad interaction time checks +let lastVisibilityHiddenAt: number | 'never-hidden' = 'never-hidden'; + +const trackVisibilityChange = () => { + unsubscribeTrackVisibilityChange?.(); + const onVisibilityChange = () => { + if (document.hidden) { + lastVisibilityHiddenAt = Date.now(); + } + }; + document.addEventListener('visibilitychange', onVisibilityChange); + + unsubscribeTrackVisibilityChange = () => { + document.removeEventListener('visibilitychange', onVisibilityChange); + }; +}; + +// todo: update monitoring api to expose filters for component names +export function initPerformanceMonitoring(options?: Partial) { + const filters = { ...DEFAULT_FILTERS, ...options }; + const monitor = Store.monitor.value; + if (!monitor) return; + + document.addEventListener('mouseover', handleMouseover); + const disconnectPerformanceListener = setupPerformanceListener((entry) => { + const target = + entry.target ?? (entry.type === 'pointer' ? currentMouseOver : null); + if (!target) { + // most likely an invariant that we should log if its violated + return; + } + const parentCompositeFiber = getFirstNamedAncestorCompositeFiber(target); + if (!parentCompositeFiber) { + return; + } + const displayName = getDisplayName(parentCompositeFiber.type); + if (!displayName || isMinified(displayName)) { + // invariant, we know its named based on getFirstNamedAncestorCompositeFiber implementation + return; + } + + const path = getInteractionPath(parentCompositeFiber, filters); + + monitor.interactions.push({ + componentName: displayName, + componentPath: path, + performanceEntry: entry, + components: new Map(), + url: window.location.toString(), + route: + Store.monitor.value?.route ?? new URL(window.location.href).pathname, + commit: Store.monitor.value?.commit ?? null, + branch: Store.monitor.value?.branch ?? null, + uniqueInteractionId: entry.id, + }); + }); + + return () => { + disconnectPerformanceListener(); + document.removeEventListener('mouseover', handleMouseover); + }; +} + +const getInteractionType = ( + eventName: string, +): 'pointer' | 'keyboard' | null => { + if (['pointerdown', 'pointerup', 'click'].includes(eventName)) { + return 'pointer'; + } + if (['keydown', 'keyup'].includes(eventName)) { + return 'keyboard'; + } + return null; +}; + +const setupPerformanceListener = ( + onEntry: (interaction: PerformanceInteraction) => void, +) => { + trackVisibilityChange(); + const longestInteractionMap = new Map(); + const interactionTargetMap = new Map(); + + const processInteractionEntry = (entry: PerformanceInteractionEntry) => { + if (!(entry.interactionId || entry.entryType === 'first-input')) return; + + if ( + entry.interactionId && + entry.target && + !interactionTargetMap.has(entry.interactionId) + ) { + interactionTargetMap.set(entry.interactionId, entry.target); + } + + const existingInteraction = longestInteractionMap.get(entry.interactionId); + + if (existingInteraction) { + if (entry.duration > existingInteraction.latency) { + existingInteraction.entries = [entry]; + existingInteraction.latency = entry.duration; + } else if ( + entry.duration === existingInteraction.latency && + entry.startTime === existingInteraction.entries[0].startTime + ) { + existingInteraction.entries.push(entry); + } + } else { + const interactionType = getInteractionType(entry.name); + if (!interactionType) return; + + const interaction: PerformanceInteraction = { + id: entry.interactionId, + latency: entry.duration, + entries: [entry], + target: entry.target, + type: interactionType, + startTime: entry.startTime, + processingStart: entry.processingStart, + processingEnd: entry.processingEnd, + duration: entry.duration, + inputDelay: entry.processingStart - entry.startTime, + processingDuration: entry.processingEnd - entry.processingStart, + presentationDelay: + entry.duration - (entry.processingEnd - entry.startTime), + timestamp: Date.now(), + timeSinceTabInactive: + lastVisibilityHiddenAt === 'never-hidden' + ? 'never-hidden' + : Date.now() - lastVisibilityHiddenAt, + visibilityState: document.visibilityState, + timeOrigin: performance.timeOrigin, + referrer: document.referrer, + }; + longestInteractionMap.set(interaction.id, interaction); + + onEntry(interaction); + } + }; + + const po = new PerformanceObserver((list) => { + const entries = list.getEntries(); + for (let i = 0, len = entries.length; i < len; i++) { + const entry = entries[i]; + processInteractionEntry(entry as PerformanceInteractionEntry); + } + }); + + try { + po.observe({ + type: 'event', + buffered: true, + durationThreshold: 16, + } as PerformanceObserverInit); + po.observe({ + type: 'first-input', + buffered: true, + }); + } catch { + /* Should collect error logs*/ + } + + return () => po.disconnect(); +}; diff --git a/packages/scan/src/core/monitor/types.ts b/packages/scan/src/core/monitor/types.ts new file mode 100644 index 0000000..48df965 --- /dev/null +++ b/packages/scan/src/core/monitor/types.ts @@ -0,0 +1,110 @@ +import type { Fiber } from 'bippy'; + +export enum Device { + DESKTOP = 0, + TABLET = 1, + MOBILE = 2, +} + +export interface Session { + id: string; + device: Device; + agent: string; + wifi: string; + cpu: number; + gpu: string | null; + mem: number; + url: string; + route: string | null; + commit: string | null; + branch: string | null; +} + +export interface Interaction { + id: string | number; // index of the interaction in the batch at ingest | server converts to a hashed string from route, type, name, path + path: Array; // the path of the interaction + name: string; // name of interaction + type: string; // type of interaction i.e pointer + time: number; // time of interaction in ms + timestamp: number; + url: string; + route: string | null; // the computed route that handles dynamic params + + // Regression tracking + commit: string | null; + branch: string | null; + + // clickhouse + ingest specific types + projectId?: string; + sessionId?: string; + uniqueInteractionId: string; + + meta?: unknown; +} + +export interface Component { + interactionId: string | number; // grouping components by interaction + name: string; + renders: number; // how many times it re-rendered / instances (normalized) + instances: number; // instances which will be used to get number of total renders by * by renders + totalTime?: number; + selfTime?: number; +} + +export interface IngestRequest { + interactions: Array; + components: Array; + session: Session; +} + +// used internally in runtime for interaction tracking. converted to Interaction when flushed +export interface InternalInteraction { + componentName: string; + url: string; + route: string | null; + commit: string | null; + branch: string | null; + uniqueInteractionId: string; // uniqueInteractionId is unique to the session and provided by performance observer. + componentPath: Array; + performanceEntry: PerformanceInteraction; + components: Map; +} +interface InternalComponentCollection { + uniqueInteractionId: string; + name: string; + renders: number; // re-renders associated with the set of components in this collection + totalTime?: number; + selfTime?: number; + fibers: Set; // no references will exist to this once array is cleared after flush, so we don't have to worry about memory leaks + retiresAllowed: number; // if our server is down and we can't collect fibers/ user has no network, it will memory leak. We need to only allow a set amount of retries before it gets gcd +} + +export interface PerformanceInteractionEntry extends PerformanceEntry { + interactionId: string; + target: Element; + name: string; + duration: number; + startTime: number; + processingStart: number; + processingEnd: number; + entryType: string; +} +export interface PerformanceInteraction { + id: string; + latency: number; + entries: Array; + target: Element; + type: 'pointer' | 'keyboard'; + startTime: number; + processingStart: number; + processingEnd: number; + duration: number; + inputDelay: number; + processingDuration: number; + presentationDelay: number; + timestamp: number; + timeSinceTabInactive: number | 'never-hidden'; + visibilityState: DocumentVisibilityState; + timeOrigin: number; + referrer: string; +} diff --git a/packages/scan/src/core/monitor/utils.ts b/packages/scan/src/core/monitor/utils.ts new file mode 100644 index 0000000..62b4009 --- /dev/null +++ b/packages/scan/src/core/monitor/utils.ts @@ -0,0 +1,131 @@ +import { onIdle } from '~web/utils/helpers'; +import { isSSR } from './constants'; +import { Device, type Session } from './types'; + +interface NetworkInformation { + connection?: { + effectiveType?: string; + }; +} + +const getDeviceType = () => { + const userAgent = navigator.userAgent; + + if ( + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + userAgent, + ) + ) { + return Device.MOBILE; + } + if (/iPad|Tablet/i.test(userAgent)) { + return Device.TABLET; + } + return Device.DESKTOP; +}; + +/** + * Measure layout time + */ +export const doubleRAF = (callback: (...args: unknown[]) => void) => { + return requestAnimationFrame(() => { + requestAnimationFrame(callback); + }); +}; + +export const generateId = () => { + const alphabet = + 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'; + let id = ''; + const randomValues = crypto.getRandomValues(new Uint8Array(21)); + for (let i = 0; i < 21; i++) { + id += alphabet[63 & randomValues[i]]; + } + return id; +}; + +/** + * @see https://deviceandbrowserinfo.com/learning_zone/articles/webgl_renderer_values + */ +const getGpuRenderer = () => { + // Prevent WEBGL_debug_renderer_info deprecation warnings in firefox + if (!('chrome' in window)) return ''; + const gl = document + .createElement('canvas') + // Get the specs for the fastest GPU available. This helps provide a better + // picture of the device's capabilities. + .getContext('webgl', { powerPreference: 'high-performance' }); + if (!gl) return ''; + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + return ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : ''; +}; + +/** + * Session is a loose way to fingerprint / identify a session. + * + * Modified from @palette.dev/browser: + * @see https://gist.github.com/aidenybai/473689493f2d5d01bbc52e2da5950b45#file-palette-dev-browser-dist-palette-dev-mjs-L554 + * DO NOT CALL THIS EVERYTIME + */ +let cachedSession: Session; +export const getSession = async ({ + commit = null, + branch = null, +}: { + commit?: string | null; + branch?: string | null; +}) => { + if (isSSR()) return null; + if (cachedSession) { + return cachedSession; + } + const id = generateId(); + const url = window.location.toString(); + /** + * WiFi connection strength + * + * Potential outputs: slow-2g, 2g, 3g, 4g + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/NetworkInformation/effectiveType + */ + const connection = (navigator as NetworkInformation).connection; + const wifi = connection?.effectiveType ?? null; + /** + * Number of CPU threads + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/hardwareConcurrency + */ + const cpu = navigator.hardwareConcurrency; + /** + * Device memory (GiB) + * + * Potential outputs: 0.25, 0.5, 1, 2, 4, 8 + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/deviceMemory + */ + // @ts-expect-error - deviceMemory is still experimental + const mem = navigator.deviceMemory; // GiB ram + + const gpuRendererPromise = new Promise((resolve) => { + onIdle(() => { + resolve(getGpuRenderer()); + }); + }); + + const session = { + id, + url, + route: null, + device: getDeviceType(), + wifi, + cpu, + mem, + gpu: await gpuRendererPromise, + agent: navigator.userAgent, + commit, + branch, + version: process.env.NPM_PACKAGE_VERSION, + }; + cachedSession = session; + return session; +}; diff --git a/packages/scan/src/core/monitor/worker/deferred.ts b/packages/scan/src/core/monitor/worker/deferred.ts new file mode 100644 index 0000000..94b9c28 --- /dev/null +++ b/packages/scan/src/core/monitor/worker/deferred.ts @@ -0,0 +1,22 @@ +export interface Deferred { + promise: Promise; + resolve: (value: unknown) => void; + reject: (value: unknown) => void; +} + +export function createDeferred(): Deferred { + let resolve: Deferred["resolve"]; + let reject: Deferred["reject"]; + return { + promise: new Promise((res, rej) => { + resolve = res; + reject = rej; + }), + resolve(value): void { + resolve(value); + }, + reject(value): void { + reject(value); + }, + }; +} diff --git a/packages/scan/src/core/monitor/worker/smol.ts b/packages/scan/src/core/monitor/worker/smol.ts new file mode 100644 index 0000000..aad0f3f --- /dev/null +++ b/packages/scan/src/core/monitor/worker/smol.ts @@ -0,0 +1,102 @@ +import { createDeferred, type Deferred } from './deferred'; + +export type SmolWorkerCallback = () => (arg: T) => Promise; + +function setupWorker(setup: () => (arg: T) => R) { + const callback = setup(); + + function success(id: number, data: unknown) { + self.postMessage([id, true, data]); + } + + function failure(id: number, data: unknown) { + self.postMessage([id, false, data]); + } + + self.addEventListener('message', (event) => { + const [id, data] = event.data; + try { + Promise.resolve(callback(data)).then( + (res) => success(id, res), + (res) => failure(id, res), + ); + } catch (error) { + failure(id, error); + } + }); +} + +function createWorker(callback: SmolWorkerCallback): Worker { + const template = `(${setupWorker.toString()})(${callback.toString()})`; + + const url = URL.createObjectURL(new Blob([template])); + + const worker = new Worker(url); + + return worker; +} + +type SmolWorkerEventType = [id: number, flag: boolean, data: unknown]; + +export class SmolWorker { + private worker?: Worker; + + private deferredMap = new Map(); + + private count = 0; + + private setup?: (arg: T) => Promise; + + public sync = false; + + constructor(private callback: SmolWorkerCallback) {} + + setupWorker(worker: Worker): void { + worker.addEventListener( + 'message', + (event: MessageEvent) => { + const [id, flag, data] = event.data; + + const deferred = this.deferredMap.get(id); + if (deferred) { + if (flag) { + deferred.resolve(data); + } else { + deferred.reject(data); + } + this.deferredMap.delete(id); + } + }, + ); + } + + async call( + data: T, + options?: { + transfer?: Array; + }, + ): Promise { + if (this.sync) { + if (!this.setup) { + this.setup = this.callback(); + } + return this.setup(data); + } + if (!this.worker) { + this.worker = createWorker(this.callback); + this.setupWorker(this.worker); + } + const deferred = createDeferred(); + const id = this.count++; + this.deferredMap.set(id, deferred); + this.worker.postMessage([id, data], { + transfer: options?.transfer, + }); + return deferred.promise as Promise; + } + + destroy(): void { + this.deferredMap.clear(); + this.worker?.terminate(); + } +} diff --git a/packages/scan/src/core/types.ts b/packages/scan/src/core/types.ts new file mode 100644 index 0000000..48df965 --- /dev/null +++ b/packages/scan/src/core/types.ts @@ -0,0 +1,110 @@ +import type { Fiber } from 'bippy'; + +export enum Device { + DESKTOP = 0, + TABLET = 1, + MOBILE = 2, +} + +export interface Session { + id: string; + device: Device; + agent: string; + wifi: string; + cpu: number; + gpu: string | null; + mem: number; + url: string; + route: string | null; + commit: string | null; + branch: string | null; +} + +export interface Interaction { + id: string | number; // index of the interaction in the batch at ingest | server converts to a hashed string from route, type, name, path + path: Array; // the path of the interaction + name: string; // name of interaction + type: string; // type of interaction i.e pointer + time: number; // time of interaction in ms + timestamp: number; + url: string; + route: string | null; // the computed route that handles dynamic params + + // Regression tracking + commit: string | null; + branch: string | null; + + // clickhouse + ingest specific types + projectId?: string; + sessionId?: string; + uniqueInteractionId: string; + + meta?: unknown; +} + +export interface Component { + interactionId: string | number; // grouping components by interaction + name: string; + renders: number; // how many times it re-rendered / instances (normalized) + instances: number; // instances which will be used to get number of total renders by * by renders + totalTime?: number; + selfTime?: number; +} + +export interface IngestRequest { + interactions: Array; + components: Array; + session: Session; +} + +// used internally in runtime for interaction tracking. converted to Interaction when flushed +export interface InternalInteraction { + componentName: string; + url: string; + route: string | null; + commit: string | null; + branch: string | null; + uniqueInteractionId: string; // uniqueInteractionId is unique to the session and provided by performance observer. + componentPath: Array; + performanceEntry: PerformanceInteraction; + components: Map; +} +interface InternalComponentCollection { + uniqueInteractionId: string; + name: string; + renders: number; // re-renders associated with the set of components in this collection + totalTime?: number; + selfTime?: number; + fibers: Set; // no references will exist to this once array is cleared after flush, so we don't have to worry about memory leaks + retiresAllowed: number; // if our server is down and we can't collect fibers/ user has no network, it will memory leak. We need to only allow a set amount of retries before it gets gcd +} + +export interface PerformanceInteractionEntry extends PerformanceEntry { + interactionId: string; + target: Element; + name: string; + duration: number; + startTime: number; + processingStart: number; + processingEnd: number; + entryType: string; +} +export interface PerformanceInteraction { + id: string; + latency: number; + entries: Array; + target: Element; + type: 'pointer' | 'keyboard'; + startTime: number; + processingStart: number; + processingEnd: number; + duration: number; + inputDelay: number; + processingDuration: number; + presentationDelay: number; + timestamp: number; + timeSinceTabInactive: number | 'never-hidden'; + visibilityState: DocumentVisibilityState; + timeOrigin: number; + referrer: string; +} diff --git a/packages/scan/src/core/utils.ts b/packages/scan/src/core/utils.ts new file mode 100644 index 0000000..9477d76 --- /dev/null +++ b/packages/scan/src/core/utils.ts @@ -0,0 +1,109 @@ +import { getType } from 'bippy'; +import type { Fiber } from 'bippy'; +import { ReactScanInternals } from '~core/index'; +import type { AggregatedRender } from '~web/utils/outline'; +import type { Render, RenderChange } from './instrumentation'; + +export const getLabelText = ( + groupedAggregatedRenders: Array, +) => { + let labelText = ''; + + const componentsByCount = new Map< + number, + Array<{ name: string; forget: boolean; time: number }> + >(); + + for (const aggregatedRender of groupedAggregatedRenders) { + const { forget, time, aggregatedCount, name } = aggregatedRender; + const components = componentsByCount.get(aggregatedCount) || []; + if (!componentsByCount.has(aggregatedCount)) { + componentsByCount.set(aggregatedCount, components); + } + components.push({ name, forget, time: time ?? 0 }); + } + + const sortedCounts = Array.from(componentsByCount.keys()).sort( + (a, b) => b - a, + ); + + const parts: Array = []; + let cumulativeTime = 0; + for (const count of sortedCounts) { + const componentGroup = componentsByCount.get(count); + if (!componentGroup) continue; + + const names = componentGroup + .slice(0, 4) + .map(({ name }) => name) + .join(', '); + let text = names; + + const totalTime = componentGroup.reduce((sum, { time }) => sum + time, 0); + const hasForget = componentGroup.some(({ forget }) => forget); + + cumulativeTime += totalTime; + + if (componentGroup.length > 4) { + text += '…'; + } + + if (count > 1) { + text += ` ×${count}`; + } + + if (hasForget) { + text = `✨${text}`; + } + + parts.push(text); + } + + labelText = parts.join(', '); + + if (!labelText.length) return null; + + if (labelText.length > 40) { + labelText = `${labelText.slice(0, 40)}…`; + } + + if (cumulativeTime >= 0.01) { + labelText += ` (${Number(cumulativeTime.toFixed(2))}ms)`; + } + + return labelText; +}; + +export const updateFiberRenderData = (fiber: Fiber, renders: Array) => { + ReactScanInternals.options.value.onRender?.(fiber, renders); + const type = getType(fiber.type) || fiber.type; + if (type && typeof type === 'function' && typeof type === 'object') { + const renderData = (type.renderData || { + count: 0, + time: 0, + renders: [], + }) as RenderData; + const firstRender = renders[0]; + renderData.count += firstRender.count; + renderData.time += firstRender.time ?? 0; + renderData.renders.push(firstRender); + type.renderData = renderData; + } +}; + +export interface RenderData { + count: number; + time: number; + renders: Array; + displayName: string | null; + type: React.ComponentType | null; + changes?: Array; +} + +export function isEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (a === null || b === null) return a === b; + if (Number.isNaN(a) && Number.isNaN(b)) return true; + return false; +} diff --git a/packages/scan/src/index.ts b/packages/scan/src/index.ts new file mode 100644 index 0000000..ffb2941 --- /dev/null +++ b/packages/scan/src/index.ts @@ -0,0 +1,9 @@ +import 'bippy'; +import { scan } from './core'; + +if (typeof window !== 'undefined') { + scan(); + window.reactScan = scan; +} + +export * from './core'; diff --git a/packages/scan/src/monitoring/next.ts b/packages/scan/src/monitoring/next.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/scan/src/outlines/canvas.ts b/packages/scan/src/outlines/canvas.ts new file mode 100644 index 0000000..b7dcbe1 --- /dev/null +++ b/packages/scan/src/outlines/canvas.ts @@ -0,0 +1,312 @@ +import type { ActiveOutline, OutlineData } from './types'; + +export const OUTLINE_ARRAY_SIZE = 7; +export const MONO_FONT = + 'Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace'; + +export const INTERPOLATION_SPEED = 0.2; +export const lerp = (start: number, end: number) => { + return Math.floor(start + (end - start) * INTERPOLATION_SPEED); +}; + +export const MAX_PARTS_LENGTH = 4; +export const MAX_LABEL_LENGTH = 40; +export const TOTAL_FRAMES = 45; + +export const primaryColor = '115,97,230'; +export const secondaryColor = '128,128,128'; + +export const getLabelText = (outlines: ActiveOutline[]): string => { + const nameByCount = new Map(); + for (const outline of outlines) { + const { name, count } = outline; + nameByCount.set(name, (nameByCount.get(name) || 0) + count); + } + + const countByNames = new Map(); + for (const [name, count] of nameByCount.entries()) { + const names = countByNames.get(count); + if (names) { + names.push(name); + } else { + countByNames.set(count, [name]); + } + } + + const partsEntries = Array.from(countByNames.entries()).sort( + ([countA], [countB]) => countB - countA, + ); + const partsLength = partsEntries.length; + let labelText = ''; + for (let i = 0; i < partsLength; i++) { + const [count, names] = partsEntries[i]; + let part = `${names.slice(0, MAX_PARTS_LENGTH).join(', ')} ×${count}`; + if (part.length > MAX_LABEL_LENGTH) { + part = `${part.slice(0, MAX_LABEL_LENGTH)}…`; + } + if (i !== partsLength - 1) { + part += ', '; + } + labelText += part; + } + + if (labelText.length > MAX_LABEL_LENGTH) { + return `${labelText.slice(0, MAX_LABEL_LENGTH)}…`; + } + + return labelText; +}; + +export const getAreaFromOutlines = (outlines: ActiveOutline[]) => { + let area = 0; + for (const outline of outlines) { + area += outline.width * outline.height; + } + return area; +}; + +export const updateOutlines = ( + activeOutlines: Map, + outlines: OutlineData[], +) => { + for (const { id, name, count, x, y, width, height, didCommit } of outlines) { + const outline: ActiveOutline = { + id, + name, + count, + x, + y, + width, + height, + frame: 0, + targetX: x, + targetY: y, + targetWidth: width, + targetHeight: height, + didCommit, + }; + const key = String(outline.id); + + const existingOutline = activeOutlines.get(key); + if (existingOutline) { + existingOutline.count++; + existingOutline.frame = 0; + existingOutline.targetX = x; + existingOutline.targetY = y; + existingOutline.targetWidth = width; + existingOutline.targetHeight = height; + existingOutline.didCommit = didCommit; + } else { + activeOutlines.set(key, outline); + } + } +}; + +export const updateScroll = ( + activeOutlines: Map, + deltaX: number, + deltaY: number, +) => { + for (const outline of activeOutlines.values()) { + const newX = outline.x - deltaX; + const newY = outline.y - deltaY; + outline.targetX = newX; + outline.targetY = newY; + } +}; + +export const initCanvas = ( + canvas: HTMLCanvasElement | OffscreenCanvas, + dpr: number, +) => { + const ctx = canvas.getContext('2d', { alpha: true }) as + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D; + if (ctx) { + ctx.scale(dpr, dpr); + } + return ctx; +}; + +export const drawCanvas = ( + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, + canvas: HTMLCanvasElement | OffscreenCanvas, + dpr: number, + activeOutlines: Map, +) => { + ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); + + const groupedOutlinesMap = new Map(); + const rectMap = new Map< + string, + { + x: number; + y: number; + width: number; + height: number; + alpha: number; + } + >(); + + for (const outline of activeOutlines.values()) { + const { + x, + y, + width, + height, + targetX, + targetY, + targetWidth, + targetHeight, + frame, + } = outline; + if (targetX !== x) { + outline.x = lerp(x, targetX); + } + if (targetY !== y) { + outline.y = lerp(y, targetY); + } + + if (targetWidth !== width) { + outline.width = lerp(width, targetWidth); + } + if (targetHeight !== height) { + outline.height = lerp(height, targetHeight); + } + + const labelKey = `${targetX ?? x},${targetY ?? y}`; + const rectKey = `${labelKey},${targetWidth ?? width},${targetHeight ?? height}`; + + const outlines = groupedOutlinesMap.get(labelKey); + if (outlines) { + outlines.push(outline); + } else { + groupedOutlinesMap.set(labelKey, [outline]); + } + + const alpha = 1 - frame / TOTAL_FRAMES; + outline.frame++; + + const rect = rectMap.get(rectKey) || { + x, + y, + width, + height, + alpha, + }; + if (alpha > rect.alpha) { + rect.alpha = alpha; + } + rectMap.set(rectKey, rect); + } + + for (const rect of rectMap.values()) { + const { x, y, width, height, alpha } = rect; + ctx.strokeStyle = `rgba(${primaryColor},${alpha})`; + ctx.lineWidth = 1; + + ctx.beginPath(); + ctx.rect(x, y, width, height); + ctx.stroke(); + ctx.fillStyle = `rgba(${primaryColor},${alpha * 0.1})`; + ctx.fill(); + } + + ctx.font = `11px ${MONO_FONT}`; + + const labelMap = new Map< + string, + { + text: string; + width: number; + height: number; + alpha: number; + x: number; + y: number; + outlines: ActiveOutline[]; + } + >(); + + ctx.textRendering = 'optimizeSpeed'; + + for (const outlines of groupedOutlinesMap.values()) { + const first = outlines[0]; + const { x, y, frame } = first; + const alpha = 1 - frame / TOTAL_FRAMES; + const text = getLabelText(outlines); + const { width } = ctx.measureText(text); + const height = 11; + labelMap.set(`${x},${y},${width},${text}`, { + text, + width, + height, + alpha, + x, + y, + outlines, + }); + + let labelY: number = y - height - 4; + + if (labelY < 0) { + labelY = 0; + } + + if (frame > TOTAL_FRAMES) { + for (const outline of outlines) { + activeOutlines.delete(String(outline.id)); + } + } + } + + const sortedLabels = Array.from(labelMap.entries()).sort( + ([_, a], [__, b]) => { + return getAreaFromOutlines(b.outlines) - getAreaFromOutlines(a.outlines); + }, + ); + + for (const [labelKey, label] of sortedLabels) { + if (!labelMap.has(labelKey)) continue; + + for (const [otherKey, otherLabel] of labelMap.entries()) { + if (labelKey === otherKey) continue; + + const { x, y, width, height } = label; + const { + x: otherX, + y: otherY, + width: otherWidth, + height: otherHeight, + } = otherLabel; + + if ( + x + width > otherX && + otherX + otherWidth > x && + y + height > otherY && + otherY + otherHeight > y + ) { + label.text = getLabelText([...label.outlines, ...otherLabel.outlines]); + label.width = ctx.measureText(label.text).width; + labelMap.delete(otherKey); + } + } + } + + for (const label of labelMap.values()) { + const { x, y, alpha, width, height, text } = label; + + let labelY: number = y - height - 4; + + if (labelY < 0) { + labelY = 0; + } + + ctx.fillStyle = `rgba(${primaryColor},${alpha})`; + ctx.fillRect(x, labelY, width + 4, height + 4); + + ctx.fillStyle = `rgba(255,255,255,${alpha})`; + ctx.fillText(text, x + 2, labelY + height); + } + + return activeOutlines.size > 0; +}; diff --git a/packages/scan/src/outlines/index.ts b/packages/scan/src/outlines/index.ts new file mode 100644 index 0000000..ff91735 --- /dev/null +++ b/packages/scan/src/outlines/index.ts @@ -0,0 +1,386 @@ +import { + type Fiber, + createFiberVisitor, + didFiberCommit, + getDisplayName, + getFiberId, + getNearestHostFibers, + instrument, + isCompositeFiber, + secure, +} from 'bippy'; +import type { BlueprintOutline, ActiveOutline, OutlineData } from './types'; +import OffscreenCanvasWorker from './offscreen-canvas.worker?worker&inline'; +import { + drawCanvas, + updateOutlines, + updateScroll, + initCanvas, + OUTLINE_ARRAY_SIZE, +} from './canvas'; + +let worker: Worker | null = null; +let canvas: HTMLCanvasElement | null = null; +let ctx: CanvasRenderingContext2D | null = null; +let dpr = 1; +let animationFrameId: number | null = null; +const activeOutlines = new Map(); + +const blueprintMap = new WeakMap(); +const blueprintMapKeys = new Set(); + +export const outlineFiber = (fiber: Fiber) => { + if (!isCompositeFiber(fiber)) return; + const name = + typeof fiber.type === 'string' ? fiber.type : getDisplayName(fiber); + if (!name) return; + const blueprint = blueprintMap.get(fiber); + const nearestFibers = getNearestHostFibers(fiber); + const didCommit = didFiberCommit(fiber); + + if (!blueprint) { + blueprintMap.set(fiber, { + name, + count: 1, + elements: nearestFibers.map((fiber) => fiber.stateNode), + didCommit: didCommit ? 1 : 0, + }); + blueprintMapKeys.add(fiber); + } else { + blueprint.count++; + } +}; + +const mergeRects = (rects: DOMRect[]) => { + const firstRect = rects[0]; + if (rects.length === 1) return firstRect; + + let minX: number | undefined; + let minY: number | undefined; + let maxX: number | undefined; + let maxY: number | undefined; + + for (let i = 0, len = rects.length; i < len; i++) { + const rect = rects[i]; + minX = minX == null ? rect.x : Math.min(minX, rect.x); + minY = minY == null ? rect.y : Math.min(minY, rect.y); + maxX = + maxX == null ? rect.x + rect.width : Math.max(maxX, rect.x + rect.width); + maxY = + maxY == null + ? rect.y + rect.height + : Math.max(maxY, rect.y + rect.height); + } + + if (minX == null || minY == null || maxX == null || maxY == null) { + return rects[0]; + } + + return new DOMRect(minX, minY, maxX - minX, maxY - minY); +}; + +export const getRectMap = ( + elements: Element[], +): Promise> => { + return new Promise((resolve) => { + const rects = new Map(); + const observer = new IntersectionObserver((entries) => { + for (let i = 0, len = entries.length; i < len; i++) { + const entry = entries[i]; + const element = entry.target; + const rect = entry.boundingClientRect; + if (entry.isIntersecting && rect.width && rect.height) { + rects.set(element, rect); + } + } + observer.disconnect(); + resolve(rects); + }); + + for (let i = 0, len = elements.length; i < len; i++) { + const element = elements[i]; + observer.observe(element); + } + }); +}; + +const SupportedArrayBuffer = + typeof SharedArrayBuffer !== 'undefined' ? SharedArrayBuffer : ArrayBuffer; + +export const flushOutlines = async () => { + const elements: Element[] = []; + + for (const fiber of blueprintMapKeys) { + const blueprint = blueprintMap.get(fiber); + if (!blueprint) continue; + for (let i = 0; i < blueprint.elements.length; i++) { + elements.push(blueprint.elements[i]); + } + } + + const rectsMap = await getRectMap(elements); + + const blueprints: BlueprintOutline[] = []; + const blueprintRects: DOMRect[] = []; + const blueprintIds: number[] = []; + + for (const fiber of blueprintMapKeys) { + const blueprint = blueprintMap.get(fiber); + if (!blueprint) continue; + const rects: DOMRect[] = []; + for (let i = 0; i < blueprint.elements.length; i++) { + const element = blueprint.elements[i]; + const rect = rectsMap.get(element); + if (!rect) continue; + rects.push(rect); + } + blueprintMap.delete(fiber); + blueprintMapKeys.delete(fiber); + if (!rects.length) continue; + blueprints.push(blueprint); + blueprintRects.push(mergeRects(rects)); + blueprintIds.push(getFiberId(fiber)); + } + + const arrayBuffer = new SupportedArrayBuffer( + blueprints.length * OUTLINE_ARRAY_SIZE * 4, + ); + const sharedView = new Float32Array(arrayBuffer); + const blueprintNames = new Array(blueprints.length); + let outlineData: OutlineData[] | undefined; + + for (let i = 0, len = blueprints.length; i < len; i++) { + const blueprint = blueprints[i]; + const id = blueprintIds[i]; + const { x, y, width, height } = blueprintRects[i]; + const { count, name, didCommit } = blueprint; + + if (worker) { + const scaledIndex = i * OUTLINE_ARRAY_SIZE; + sharedView[scaledIndex] = id; + sharedView[scaledIndex + 1] = count; + sharedView[scaledIndex + 2] = x; + sharedView[scaledIndex + 3] = y; + sharedView[scaledIndex + 4] = width; + sharedView[scaledIndex + 5] = height; + sharedView[scaledIndex + 6] = didCommit; + blueprintNames[i] = name; + } else { + outlineData ||= new Array(blueprints.length); + outlineData[i] = { + id, + name, + count, + x, + y, + width, + height, + didCommit: didCommit as 0 | 1, + }; + } + } + + if (worker) { + worker.postMessage({ + type: 'draw-outlines', + data: arrayBuffer, + names: blueprintNames, + }); + } else if (canvas && ctx && outlineData) { + updateOutlines(activeOutlines, outlineData); + if (!animationFrameId) { + animationFrameId = requestAnimationFrame(draw); + } + } +}; + +const draw = () => { + if (!ctx || !canvas) return; + + const shouldContinue = drawCanvas(ctx, canvas, dpr, activeOutlines); + + if (shouldContinue) { + animationFrameId = requestAnimationFrame(draw); + } else { + animationFrameId = null; + } +}; + +const CANVAS_HTML_STR = ``; + +const IS_OFFSCREEN_CANVAS_WORKER_SUPPORTED = + typeof OffscreenCanvas !== 'undefined' && typeof Worker !== 'undefined'; + +const getDpr = () => { + return Math.min(window.devicePixelRatio || 1, 2); +}; + +export const getCanvasEl = () => { + const host = document.createElement('div'); + host.setAttribute('data-react-scan', 'true'); + const shadowRoot = host.attachShadow({ mode: 'open' }); + + shadowRoot.innerHTML = CANVAS_HTML_STR; + const canvasEl = shadowRoot.firstChild as HTMLCanvasElement; + if (!canvasEl) return null; + + dpr = getDpr(); + canvas = canvasEl; + + const { innerWidth, innerHeight } = window; + canvasEl.style.width = `${innerWidth}px`; + canvasEl.style.height = `${innerHeight}px`; + const width = innerWidth * dpr; + const height = innerHeight * dpr; + canvasEl.width = width; + canvasEl.height = height; + + if (IS_OFFSCREEN_CANVAS_WORKER_SUPPORTED) { + try { + worker = new OffscreenCanvasWorker(); + const offscreenCanvas = canvasEl.transferControlToOffscreen(); + + worker.postMessage( + { + type: 'init', + canvas: offscreenCanvas, + width: canvasEl.width, + height: canvasEl.height, + dpr, + }, + [offscreenCanvas], + ); + } catch {} + } + + if (!worker) { + ctx = initCanvas(canvasEl, dpr) as CanvasRenderingContext2D; + } + + let isResizeScheduled = false; + window.addEventListener('resize', () => { + if (!isResizeScheduled) { + isResizeScheduled = true; + setTimeout(() => { + const width = window.innerWidth; + const height = window.innerHeight; + dpr = getDpr(); + canvasEl.style.width = `${width}px`; + canvasEl.style.height = `${height}px`; + if (worker) { + worker.postMessage({ + type: 'resize', + width, + height, + dpr, + }); + } else { + canvasEl.width = width * dpr; + canvasEl.height = height * dpr; + if (ctx) { + ctx.resetTransform(); + ctx.scale(dpr, dpr); + } + draw(); + } + isResizeScheduled = false; + }); + } + }); + + let prevScrollX = window.scrollX; + let prevScrollY = window.scrollY; + let isScrollScheduled = false; + + window.addEventListener('scroll', () => { + if (!isScrollScheduled) { + isScrollScheduled = true; + setTimeout(() => { + const { scrollX, scrollY } = window; + const deltaX = scrollX - prevScrollX; + const deltaY = scrollY - prevScrollY; + prevScrollX = scrollX; + prevScrollY = scrollY; + if (worker) { + worker.postMessage({ + type: 'scroll', + deltaX, + deltaY, + }); + } else { + requestAnimationFrame(() => { + updateScroll(activeOutlines, deltaX, deltaY); + }); + } + isScrollScheduled = false; + }, 16 * 2); + } + }); + + setInterval(() => { + if (blueprintMapKeys.size) { + flushOutlines(); + } + }, 16 * 2); + + shadowRoot.appendChild(canvasEl); + return host; +}; + +export const hasStopped = () => { + return globalThis.__REACT_SCAN_STOP__; +}; + +export const stop = () => { + globalThis.__REACT_SCAN_STOP__ = true; + cleanup(); +}; + +let hasCleanedUp = false; +export const cleanup = () => { + if (hasCleanedUp) return; + hasCleanedUp = true; + const host = document.querySelector('[data-react-scan]'); + if (host) { + host.remove(); + } +}; + +export const initReactScanOverlay = () => { + cleanup(); + if (hasStopped()) return; + const visit = createFiberVisitor({ + onRender(fiber) { + if (document.hidden) return; + outlineFiber(fiber); + }, + onError() {}, + }); + + instrument( + secure( + { + onActive() { + if (hasStopped()) return; + const host = getCanvasEl(); + if (host) { + document.documentElement.appendChild(host); + } + }, + onCommitFiberRoot(rendererID, root) { + if (hasStopped()) return cleanup(); + visit(rendererID, root); + }, + }, + { + dangerouslyRunInProduction: true, + onError(error) { + console.warn( + 'React Scan did not install correctly.\n\n{link to install doc}', + error, + ); + }, + }, + ), + ); +}; diff --git a/packages/scan/src/outlines/offscreen-canvas.worker.ts b/packages/scan/src/outlines/offscreen-canvas.worker.ts new file mode 100644 index 0000000..9003c4a --- /dev/null +++ b/packages/scan/src/outlines/offscreen-canvas.worker.ts @@ -0,0 +1,108 @@ +import type { ActiveOutline } from './types'; +import { drawCanvas, initCanvas, OUTLINE_ARRAY_SIZE } from './canvas'; + +let canvas: OffscreenCanvas | null = null; +let ctx: OffscreenCanvasRenderingContext2D | null = null; +let dpr = 1; + +const activeOutlines: Map = new Map(); +let animationFrameId: number | null = null; + +const draw = () => { + if (!ctx || !canvas) return; + + const shouldContinue = drawCanvas(ctx, canvas, dpr, activeOutlines); + + if (shouldContinue) { + animationFrameId = requestAnimationFrame(draw); + } else { + animationFrameId = null; + } +}; + +self.onmessage = (event) => { + const { type } = event.data; + + if (type === 'init') { + canvas = event.data.canvas; + dpr = event.data.dpr; + + if (canvas) { + canvas.width = event.data.width; + canvas.height = event.data.height; + ctx = initCanvas(canvas, dpr) as OffscreenCanvasRenderingContext2D; + } + } + + if (!canvas || !ctx) return; + + if (type === 'resize') { + dpr = event.data.dpr; + canvas.width = event.data.width * dpr; + canvas.height = event.data.height * dpr; + ctx.resetTransform(); + ctx.scale(dpr, dpr); + draw(); + + return; + } + + if (type === 'draw-outlines') { + const { data, names } = event.data; + + const sharedView = new Float32Array(data); + for (let i = 0; i < sharedView.length; i += OUTLINE_ARRAY_SIZE) { + const x = sharedView[i + 2]; + const y = sharedView[i + 3]; + const width = sharedView[i + 4]; + const height = sharedView[i + 5]; + + const didCommit = sharedView[i + 6] as 0 | 1; + const outline = { + id: sharedView[i], + name: names[i / OUTLINE_ARRAY_SIZE], + count: sharedView[i + 1], + x, + y, + width, + height, + frame: 0, + targetX: x, + targetY: y, + targetWidth: width, + targetHeight: height, + didCommit, + }; + const key = String(outline.id); + + const existingOutline = activeOutlines.get(key); + if (existingOutline) { + existingOutline.count++; + existingOutline.frame = 0; + existingOutline.targetX = x; + existingOutline.targetY = y; + existingOutline.targetWidth = width; + existingOutline.targetHeight = height; + existingOutline.didCommit = didCommit; + } else { + activeOutlines.set(key, outline); + } + } + + if (!animationFrameId) { + animationFrameId = requestAnimationFrame(draw); + } + + return; + } + + if (type === 'scroll') { + const { deltaX, deltaY } = event.data; + for (const outline of activeOutlines.values()) { + const newX = outline.x - deltaX; + const newY = outline.y - deltaY; + outline.targetX = newX; + outline.targetY = newY; + } + } +}; diff --git a/packages/scan/src/outlines/types.ts b/packages/scan/src/outlines/types.ts new file mode 100644 index 0000000..a9888e7 --- /dev/null +++ b/packages/scan/src/outlines/types.ts @@ -0,0 +1,75 @@ +export interface OutlineData { + id: number; + name: string; + count: number; + x: number; + y: number; + width: number; + height: number; + didCommit: 0 | 1; +} + +export type InlineOutlineData = [ + /** + * id + */ + number, + /** + * count + */ + number, + /** + * x + */ + number, + /** + * y + */ + number, + /** + * width + */ + number, + /** + * height + */ + number, + /** + * didCommit + */ + 0 | 1, +]; + +export interface ActiveOutline { + id: number; + name: string; + count: number; + x: number; + y: number; + width: number; + height: number; + targetX: number; + targetY: number; + targetWidth: number; + targetHeight: number; + frame: number; + didCommit: 1 | 0; +} + +export interface BlueprintOutline { + name: string; + count: number; + elements: Element[]; + didCommit: 1 | 0; +} + +declare global { + var __REACT_SCAN_STOP__: boolean; + var ReactScan: { + hasStopped: () => boolean; + stop: () => void; + cleanup: () => void; + init: () => void; + flushOutlines: () => void; + }; +} diff --git a/packages/scan/src/types.ts b/packages/scan/src/types.ts new file mode 100644 index 0000000..188aff4 --- /dev/null +++ b/packages/scan/src/types.ts @@ -0,0 +1,21 @@ +type ReactScanInternals = typeof import('./core/index')['ReactScanInternals']; +type Scan = typeof import('./index')['scan']; + +declare global { + var __REACT_SCAN__: { + ReactScanInternals: ReactScanInternals; + }; + var reactScan: Scan; + var scheduler: { + postTask: (cb: unknown, options: { priority: string }) => void; + }; + + type TTimer = NodeJS.Timeout; + + interface Window { + isReactScanExtension?: boolean; + reactScan: Scan; + } +} + +export {}; diff --git a/packages/scan/src/vite-env.d.ts b/packages/scan/src/vite-env.d.ts new file mode 100644 index 0000000..6306a66 --- /dev/null +++ b/packages/scan/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// +/// + +declare module 'virtual:svg-sprite' { + const content: string; + export default content; +} + +declare module '*.css' { + const styles: string; + export default styles; +} diff --git a/packages/scan/src/web/assets/css/styles.css b/packages/scan/src/web/assets/css/styles.css new file mode 100644 index 0000000..b27568a --- /dev/null +++ b/packages/scan/src/web/assets/css/styles.css @@ -0,0 +1,405 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + outline: none !important; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + backface-visibility: hidden; + + /* WebKit (Chrome, Safari, Edge) specific scrollbar styles */ + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + border-radius: 10px; + background: transparent; + } + + &::-webkit-scrollbar-thumb { + border-radius: 10px; + background: rgba(255, 255, 255, 0.3); + } + + &::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, .4); + } +} + +@-moz-document url-prefix() { + * { + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.4) transparent; + scrollbar-width: 6px; + } +} + + +button { + @apply hover:bg-none; + @apply outline-none; + @apply border-none; + @apply transition-colors duration-150 ease-linear; + @apply cursor-pointer; +} + +svg { + @apply w-auto h-auto; + @apply pointer-events-none; +} + +/* + Using CSS content with data attributes is more performant than: + 1. React re-renders with JSX text content + 2. Direct DOM manipulation methods: + - element.textContent (creates/updates text nodes, triggers repaint) + - element.innerText (triggers reflow by computing styles & layout) + - element.innerHTML (heavy parsing, triggers reflow, security risks) + 3. Multiple data attributes with complex CSS concatenation + + This approach: + - Avoids React reconciliation + - Uses browser's native CSS engine (optimized content updates) + - Minimizes main thread work + - Reduces DOM operations + - Avoids forced reflows (layout recalculation) + - Only triggers necessary repaints + - Keeps pseudo-element updates in render layer +*/ +.with-data-text { + overflow: hidden; + &::before { + content: attr(data-text); + @apply block; + @apply truncate; + } +} + +#react-scan-toolbar { + @apply fixed left-0 top-0; + @apply flex flex-col; + @apply rounded-lg shadow-lg; + @apply font-mono text-[13px] text-white; + @apply bg-black; + @apply select-none; + @apply cursor-move; + @apply opacity-0; + @apply z-[2147483678]; + @apply animate-fade-in animation-duration-300 animation-delay-300; + @apply shadow-[0_4px_12px_rgba(0,0,0,0.2)]; +} + +.button { + &:hover { + background: rgba(255, 255, 255, 0.1); + } + + &:active { + background: rgba(255, 255, 255, 0.15); + } +} + +.resize-line-wrapper { + @apply absolute; + @apply overflow-hidden; +} + +.resize-line { + @apply absolute inset-0; + @apply overflow-hidden; + @apply bg-black/90; + @apply transition-all duration-150; + + svg { + @apply absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2; + } +} + +.resize-right, +.resize-left { + @apply inset-y-0; + @apply w-6; + @apply cursor-ew-resize; + + .resize-line-wrapper { + @apply inset-y-0; + @apply w-1/2; + } + + &:hover { + .resize-line { + @apply translate-x-0; + } + } +} +.resize-right { + @apply right-0; + @apply translate-x-1/2; + + .resize-line-wrapper { + @apply right-0; + } + .resize-line { + @apply rounded-r-lg; + @apply -translate-x-full; + } +} + +.resize-left { + @apply left-0; + @apply -translate-x-1/2; + + .resize-line-wrapper { + @apply left-0; + } + .resize-line { + @apply rounded-l-lg; + @apply translate-x-full; + } +} + +.resize-top, +.resize-bottom { + @apply inset-x-0; + @apply h-6; + @apply cursor-ns-resize; + + .resize-line-wrapper { + @apply inset-x-0; + @apply h-1/2; + } + + &:hover { + .resize-line { + @apply translate-y-0; + } + } +} +.resize-top { + @apply top-0; + @apply -translate-y-1/2; + + .resize-line-wrapper { + @apply top-0; + } + .resize-line { + @apply rounded-t-lg; + @apply translate-y-full; + } +} + +.resize-bottom { + @apply bottom-0; + @apply translate-y-1/2; + + .resize-line-wrapper { + @apply bottom-0; + } + .resize-line { + @apply rounded-b-lg; + @apply -translate-y-full; + } +} + +/* HEADER */ +.react-scan-header { + @apply flex items-center gap-x-2; + @apply pl-3 pr-2; + @apply min-h-9; + @apply border-b-1 border-white/10; + @apply whitespace-nowrap overflow-hidden; +} + +.react-scan-replay-button, +.react-scan-close-button { + @apply flex items-center; + @apply p-1; + @apply min-w-fit; + @apply rounded; + @apply transition-colors duration-150; +} + +.react-scan-replay-button { + @apply relative; + @apply overflow-hidden; + @apply !bg-purple-500/50; + + &:hover { + @apply bg-purple-500/25; + } + + &.disabled { + @apply opacity-50; + @apply pointer-events-none; + } + + &:before { + content: ''; + @apply absolute; + @apply inset-0; + @apply -translate-x-full; + animation: shimmer 2s infinite; + background: linear-gradient(to right, + transparent, + rgba(142, 97, 227, 0.3), + transparent); + } +} + +.react-scan-close-button { + @apply bg-white/10; + + &:hover { + @apply bg-white/15; + } +} + +@keyframes shimmer { + 100% { + transform: translateX(100%); + } +} + +.react-scan-inspector { + font-size: 13px; + color: #fff; + width: 100%; +} + +.react-scan-section { + @apply flex flex-col py-1; + @apply py-2 px-4; + @apply bg-black text-[#888]; + @apply before:content-[attr(data-section)] before:text-gray-500; + + > .react-scan-property { + @apply -ml-3.5; + } +} + +.react-scan-property { + @apply relative; + @apply flex flex-col; + @apply pl-8; + @apply border-l-1 border-transparent; + @apply overflow-hidden; +} + +.react-scan-property-content { + @apply flex-1 flex flex-col; + @apply py-1 min-h-6; + @apply max-w-full; + @apply overflow-hidden; +} + +.react-scan-string { + color: #9ecbff; +} + +.react-scan-number { + color: #79c7ff; +} + +.react-scan-boolean { + color: #56b6c2; +} + +.react-scan-key { + @apply min-w-8 max-w-60 truncate text-white; +} + +.react-scan-input { + @apply text-white; + @apply bg-black; +} + +@keyframes blink { + from { opacity: 1; } + to { opacity: 0; } +} + +.react-scan-arrow { + @apply absolute top-1 left-7; + @apply flex items-center justify-center; + @apply cursor-pointer; + @apply w-6 h-6; + @apply -translate-x-full; + @apply z-10; + + > svg { + @apply transition-transform duration-150; + } +} + +.react-scan-expandable { + @apply grid grid-rows-[0fr]; + @apply transition-all duration-150; + + &.react-scan-expanded { + @apply pt-2; + @apply grid-rows-[1fr]; + } +} + +.react-scan-nested { + @apply relative; + /* @apply border-l-1 border-gray-500/30; */ + @apply overflow-hidden; + + &:before { + content: ''; + @apply absolute top-0 left-0; + @apply w-[1px] h-full; + @apply bg-gray-500/30; + } +} + +.react-scan-hidden { + display: none; +} + +.react-scan-array-container { + overflow-y: auto; + @apply ml-5; + margin-top: 8px; + border-left: 1px solid rgba(255, 255, 255, 0.1); + padding-left: 8px; +} + +.react-scan-preview-line { + @apply relative; + @apply flex items-center min-h-6 gap-x-2; +} + +.react-scan-flash-overlay { + position: absolute; + inset: 0; + opacity: 0; + z-index: 999999; + mix-blend-mode: multiply; + background: rgba(142, 97, 227, 0.9); + transition: opacity 150ms ease-in; + pointer-events: none; +} + +.react-scan-flash-active { + opacity: 0.4; + transition: opacity 300ms ease-in-out; +} + +.react-scan-inspector-overlay { + opacity: 0; + transition: opacity 300ms ease-in-out; + + &.fade-out { + opacity: 0; + } + + &.fade-in { + opacity: 1; + } +} diff --git a/packages/scan/src/web/assets/svgs/icon/alert.svg b/packages/scan/src/web/assets/svgs/icon/alert.svg new file mode 100644 index 0000000..4eba86d --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/alert.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/scan/src/web/assets/svgs/icon/check.svg b/packages/scan/src/web/assets/svgs/icon/check.svg new file mode 100644 index 0000000..c3f285a --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/scan/src/web/assets/svgs/icon/chevron-right.svg b/packages/scan/src/web/assets/svgs/icon/chevron-right.svg new file mode 100644 index 0000000..f505d24 --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/scan/src/web/assets/svgs/icon/close.svg b/packages/scan/src/web/assets/svgs/icon/close.svg new file mode 100644 index 0000000..d7d8ee3 --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/scan/src/web/assets/svgs/icon/copy.svg b/packages/scan/src/web/assets/svgs/icon/copy.svg new file mode 100644 index 0000000..9ab69d2 --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/scan/src/web/assets/svgs/icon/ellipsis.svg b/packages/scan/src/web/assets/svgs/icon/ellipsis.svg new file mode 100644 index 0000000..b441423 --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/scan/src/web/assets/svgs/icon/eye-off.svg b/packages/scan/src/web/assets/svgs/icon/eye-off.svg new file mode 100644 index 0000000..ce3df20 --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/eye-off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/scan/src/web/assets/svgs/icon/eye.svg b/packages/scan/src/web/assets/svgs/icon/eye.svg new file mode 100644 index 0000000..a31567e --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/scan/src/web/assets/svgs/icon/focus.svg b/packages/scan/src/web/assets/svgs/icon/focus.svg new file mode 100644 index 0000000..d062eea --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/focus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/scan/src/web/assets/svgs/icon/inspect.svg b/packages/scan/src/web/assets/svgs/icon/inspect.svg new file mode 100644 index 0000000..6fdb4e0 --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/inspect.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/scan/src/web/assets/svgs/icon/next.svg b/packages/scan/src/web/assets/svgs/icon/next.svg new file mode 100644 index 0000000..58856ad --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/next.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/scan/src/web/assets/svgs/icon/previous.svg b/packages/scan/src/web/assets/svgs/icon/previous.svg new file mode 100644 index 0000000..2bd1870 --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/previous.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/scan/src/web/assets/svgs/icon/replay.svg b/packages/scan/src/web/assets/svgs/icon/replay.svg new file mode 100644 index 0000000..8216343 --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/replay.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/scan/src/web/assets/svgs/icon/scan-eye.svg b/packages/scan/src/web/assets/svgs/icon/scan-eye.svg new file mode 100644 index 0000000..8216343 --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/scan-eye.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/scan/src/web/assets/svgs/icon/volume-off.svg b/packages/scan/src/web/assets/svgs/icon/volume-off.svg new file mode 100644 index 0000000..4414596 --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/volume-off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/scan/src/web/assets/svgs/icon/volume-on.svg b/packages/scan/src/web/assets/svgs/icon/volume-on.svg new file mode 100644 index 0000000..3929f5f --- /dev/null +++ b/packages/scan/src/web/assets/svgs/icon/volume-on.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/scan/src/web/components/copy-to-clipboard/index.tsx b/packages/scan/src/web/components/copy-to-clipboard/index.tsx new file mode 100644 index 0000000..a4f96c3 --- /dev/null +++ b/packages/scan/src/web/components/copy-to-clipboard/index.tsx @@ -0,0 +1,82 @@ +import { useRef, useState, useEffect, useCallback, useMemo } from "preact/hooks"; +import { memo } from "preact/compat"; +import { cn } from "~web/utils/helpers"; +import { Icon } from "../icon"; + +interface CopyToClipboardProps { + text: string; + children?: (props: { ClipboardIcon: JSX.Element; onClick: (e: MouseEvent) => void }) => JSX.Element; + onCopy?: (success: boolean, text: string) => void; + className?: string; + iconSize?: number; +} + +export const CopyToClipboard = memo((props: CopyToClipboardProps): JSX.Element => { + const { + text, + children, + onCopy, + className, + iconSize = 14, + } = props; + + const refTimeout = useRef(); + const [isCopied, setIsCopied] = useState(false); + + useEffect(() => { + if (isCopied) { + refTimeout.current = setTimeout(() => setIsCopied(false), 600); + return () => { + clearTimeout(refTimeout?.current); + }; + } + }, [isCopied]); + + const copyToClipboard = useCallback((e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + void (async () => { + try { + await navigator.clipboard.writeText(text); + setIsCopied(true); + onCopy?.(true, text); + } catch { + onCopy?.(false, text); + } + })(); + }, [text, onCopy]); + + const ClipboardIcon = useMemo((): JSX.Element => ( + + ), [className, copyToClipboard, iconSize, isCopied]); + + if (!children) { + return ClipboardIcon; + } + + return children({ + ClipboardIcon, + onClick: copyToClipboard, + }); +}); diff --git a/packages/scan/src/web/components/icon/index.tsx b/packages/scan/src/web/components/icon/index.tsx new file mode 100644 index 0000000..1954d68 --- /dev/null +++ b/packages/scan/src/web/components/icon/index.tsx @@ -0,0 +1,58 @@ +import type { JSX } from 'preact'; +import { + type ForwardedRef, + forwardRef, +} from 'preact/compat'; + +export interface SVGIconProps { + size?: number | Array; + name: string; + fill?: string; + stroke?: string; + className?: string; + externalURL?: string; + style?: JSX.CSSProperties; +} + +export const Icon = forwardRef((props: SVGIconProps, ref: ForwardedRef) => { + const { + size = 15, + name, + fill = 'currentColor', + stroke = 'currentColor', + className, + externalURL = '', + style, + } = props; + + const width = Array.isArray(size) ? size[0] : size; + const height = Array.isArray(size) ? size[1] || size[0] : size; + + const attributes = { + width: `${width}px`, + height: `${height}px`, + fill, + stroke, + className, + style, + }; + + const path = `${externalURL}#${name}`; + + return ( + + {name} + + + ); +}); diff --git a/packages/scan/src/web/components/inspector/flash-overlay.ts b/packages/scan/src/web/components/inspector/flash-overlay.ts new file mode 100644 index 0000000..44247e4 --- /dev/null +++ b/packages/scan/src/web/components/inspector/flash-overlay.ts @@ -0,0 +1,114 @@ +interface FlashEntry { + element: HTMLElement; + overlay: HTMLElement; + scrollCleanup?: () => void; +} + +const fadeOutTimers = new WeakMap>(); + +const trackElementPosition = ( + element: Element, + callback: (element: Element) => void, +): (() => void) => { + const handleScroll = () => { + callback(element); + }; + + document.addEventListener('scroll', handleScroll, { + passive: true, + capture: true, + }); + + return () => { + document.removeEventListener('scroll', handleScroll, { capture: true }); + }; +}; + +export const flashManager = { + activeFlashes: new Map(), + + create(container: HTMLElement) { + const existingOverlay = container.querySelector( + '.react-scan-flash-overlay', + ); + + const overlay = + existingOverlay instanceof HTMLElement + ? existingOverlay + : (() => { + const newOverlay = document.createElement('div'); + newOverlay.className = 'react-scan-flash-overlay'; + container.appendChild(newOverlay); + + const scrollCleanup = trackElementPosition(container, () => { + if (container.querySelector('.react-scan-flash-overlay')) { + this.create(container); + } + }); + + this.activeFlashes.set(container, { + element: container, + overlay: newOverlay, + scrollCleanup, + }); + + return newOverlay; + })(); + + const existingTimer = fadeOutTimers.get(overlay); + if (existingTimer) { + clearTimeout(existingTimer); + fadeOutTimers.delete(overlay); + } + + requestAnimationFrame(() => { + overlay.style.transition = 'none'; + overlay.style.opacity = '0.9'; + + const timerId = setTimeout(() => { + overlay.style.transition = 'opacity 150ms ease-out'; + overlay.style.opacity = '0'; + + const cleanupTimer = setTimeout(() => { + if (overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + const entry = this.activeFlashes.get(container); + if (entry?.scrollCleanup) { + entry.scrollCleanup(); + } + this.activeFlashes.delete(container); + fadeOutTimers.delete(overlay); + }, 150); + + fadeOutTimers.set(overlay, cleanupTimer); + }, 300); + + fadeOutTimers.set(overlay, timerId); + }); + }, + + cleanup(container: HTMLElement) { + const entry = this.activeFlashes.get(container); + if (entry) { + const existingTimer = fadeOutTimers.get(entry.overlay); + if (existingTimer) { + clearTimeout(existingTimer); + fadeOutTimers.delete(entry.overlay); + } + if (entry.overlay.parentNode) { + entry.overlay.parentNode.removeChild(entry.overlay); + } + if (entry.scrollCleanup) { + entry.scrollCleanup(); + } + this.activeFlashes.delete(container); + } + }, + + cleanupAll() { + for (const entry of this.activeFlashes.entries()) { + this.cleanup(entry.element); + } + }, +}; diff --git a/packages/scan/src/web/components/inspector/index.tsx b/packages/scan/src/web/components/inspector/index.tsx new file mode 100644 index 0000000..bce708f --- /dev/null +++ b/packages/scan/src/web/components/inspector/index.tsx @@ -0,0 +1,1215 @@ +import { useEffect, useRef, useState, useMemo, useCallback } from 'preact/hooks'; +import { getDisplayName } from 'bippy'; +import type { Fiber } from 'bippy'; +import { Component } from 'preact'; +import { memo } from 'preact/compat'; +import { signal } from "@preact/signals"; +import { Store } from '~core/index'; +import { isEqual } from '~core/utils'; +import { cn, tryOrElse } from '~web/utils/helpers'; +import { CopyToClipboard } from '~web/components/copy-to-clipboard'; +import { Icon } from '~web/components/icon'; +import { getCompositeComponentFromElement, getOverrideMethods } from './utils'; +import { + getChangedProps, + getChangedContext, + getStateChangeCount, + getPropsChangeCount, + getContextChangeCount, + getCurrentState, + getCurrentProps, + getCurrentContext, + resetStateTracking, + getStateNames, + getChangedState +} from './overlay/utils'; +import { flashManager } from './flash-overlay'; + +interface InspectorState { + fiber: Fiber | null; + changes: { + state: Set; + props: Set; + context: Set; + }; + current: { + state: Record; + props: Record; + context: Record; + }; +} + +type TypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array; + +type InspectableValue = + | Record + | Array + | Map + | Set + | ArrayBuffer + | DataView + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array; + +interface PropertyElementProps { + name: string; + value: unknown; + section: string; + level: number; + parentPath?: string; + objectPathMap?: WeakMap>; + changedKeys?: Set; + allowEditing?: boolean; +} + +interface PropertySectionProps { + title: string; + section: 'props' | 'state' | 'context'; +} + +interface EditableValueProps { + value: unknown; + onSave: (newValue: unknown) => void; + onCancel: () => void; +} + +type IterableEntry = [key: string | number, value: unknown]; + +const EXPANDED_PATHS = new Set(); +const lastRendered = new Map(); +let lastInspectedFiber: Fiber | null = null; + +const THROTTLE_MS = 16; +const DEBOUNCE_MS = 150; + + +const inspectorState = signal({ + fiber: null, + changes: { + state: new Set(), + props: new Set(), + context: new Set() + }, + current: { + state: {}, + props: {}, + context: {} + } +}); + +class InspectorErrorBoundary extends Component { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return null; + } + return this.props.children; + } +} + +const isExpandable = (value: unknown): value is InspectableValue => { + if (value === null || typeof value !== 'object' || isPromise(value)) { + return false; + } + + if (value instanceof ArrayBuffer) { + return true; + } + + if (value instanceof DataView) { + return true; + } + + if (ArrayBuffer.isView(value)) { + return true; + } + + if (value instanceof Map || value instanceof Set) { + return value.size > 0; + } + + if (Array.isArray(value)) { + return value.length > 0; + } + + return Object.keys(value).length > 0; +}; + +const isPromise = (value: unknown): value is Promise => { + return value && (value instanceof Promise || (typeof value === 'object' && 'then' in value)); +}; + +const isEditableValue = (value: unknown, parentPath?: string): boolean => { + if (value === null || value === undefined) return true; + + if (isPromise(value)) return false; + + if (typeof value === 'function') { + return false; + } + + if (parentPath) { + const parts = parentPath.split('.'); + let currentPath = ''; + for (const part of parts) { + currentPath = currentPath ? `${currentPath}.${part}` : part; + const obj = lastRendered.get(currentPath); + if (obj instanceof DataView || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)) { + return false; + } + } + } + + switch (value.constructor) { + case Date: + case RegExp: + case Error: + return true; + default: + switch (typeof value) { + case 'string': + case 'number': + case 'boolean': + case 'bigint': + return true; + default: + return false; + } + } +}; + +const getPath = ( + componentName: string, + section: string, + parentPath: string, + key: string, +): string => { + if (parentPath) { + return `${componentName}.${parentPath}.${key}`; + } + + if (section === 'context' && !key.startsWith('context.')) { + return `${componentName}.${section}.context.${key}`; + } + + return `${componentName}.${section}.${key}`; +}; + +const getArrayLength = (obj: ArrayBufferView): number => { + if (obj instanceof DataView) { + return obj.byteLength; + } + return (obj as TypedArray).length; +}; + +const sanitizeString = (value: string): string => { + return value + .replace(/[<>]/g, '') + .replace(/javascript:/gi, '') + .replace(/data:/gi, '') + .replace(/on\w+=/gi, '') + .slice(0, 50000); +}; + +const sanitizeErrorMessage = (error: string): string => { + return error + .replace(/[<>]/g, '') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/'); +}; + +const formatValue = (value: unknown): string => { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (isPromise(value)) return 'Promise'; + + switch (true) { + case value instanceof Map: + return `Map(${value.size})`; + case value instanceof Set: + return `Set(${value.size})`; + case value instanceof Date: + return value.toLocaleString(undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }).replace(/[/,-]/g, '.'); + case value instanceof RegExp: + return 'RegExp'; + case value instanceof Error: + return 'Error'; + case value instanceof ArrayBuffer: + return `ArrayBuffer(${value.byteLength})`; + case value instanceof DataView: + return `DataView(${value.byteLength})`; + case ArrayBuffer.isView(value): + return `${value.constructor.name}(${getArrayLength(value)})`; + case Array.isArray(value): + return `Array(${value.length})`; + default: + switch (typeof value) { + case 'string': + return `"${value}"`; + case 'number': + case 'boolean': + case 'bigint': + return String(value); + case 'symbol': + return value.toString(); + case 'object': { + const keys = Object.keys(value); + if (keys.length <= 5) return `{${keys.join(', ')}}`; + return `{${keys.slice(0, 5).join(', ')}, ...${keys.length - 5}}`; + } + default: + return typeof value; + } + } +}; + +const formatForClipboard = (value: unknown): string => { + try { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (isPromise(value)) return 'Promise'; + + switch (true) { + case value instanceof Date: + return value.toISOString(); + case value instanceof RegExp: + return value.toString(); + case value instanceof Error: + return `${value.name}: ${value.message}`; + case value instanceof Map: + return JSON.stringify(Array.from(value.entries()), null, 2); + case value instanceof Set: + return JSON.stringify(Array.from(value), null, 2); + case value instanceof DataView: + return JSON.stringify(Array.from(new Uint8Array(value.buffer)), null, 2); + case value instanceof ArrayBuffer: + return JSON.stringify(Array.from(new Uint8Array(value)), null, 2); + case ArrayBuffer.isView(value) && 'length' in value: + return JSON.stringify(Array.from(value as unknown as ArrayLike), null, 2); + case Array.isArray(value): + return JSON.stringify(value, null, 2); + case typeof value === 'object': + return JSON.stringify(value, null, 2); + default: + return String(value); + } + } catch { + return String(value); + } +}; + +const parseArrayValue = (value: string): Array => { + if (value.trim() === '[]') return []; + + const result: Array = []; + let current = ''; + let depth = 0; + let inString = false; + let escapeNext = false; + + for (let i = 0; i < value.length; i++) { + const char = value[i]; + + if (escapeNext) { + current += char; + escapeNext = false; + continue; + } + + if (char === '\\') { + escapeNext = true; + current += char; + continue; + } + + if (char === '"') { + inString = !inString; + current += char; + continue; + } + + if (inString) { + current += char; + continue; + } + + if (char === '[' || char === '{') { + depth++; + current += char; + continue; + } + + if (char === ']' || char === '}') { + depth--; + current += char; + continue; + } + + if (char === ',' && depth === 0) { + if (current.trim()) { + result.push(parseValue(current.trim(), '')); + } + current = ''; + continue; + } + + current += char; + } + + if (current.trim()) { + result.push(parseValue(current.trim(), '')); + } + + return result; +}; + +const parseValue = (value: string, currentType: unknown): unknown => { + try { + if (typeof currentType === 'number') return Number(value); + if (typeof currentType === 'string') return value; + if (typeof currentType === 'boolean') return value === 'true'; + if (typeof currentType === 'bigint') return BigInt(value); + if (currentType === null) return null; + if (currentType === undefined) return undefined; + + if (currentType instanceof RegExp) { + try { + const match = /^\/(?.*)\/(?[gimuy]*)$/.exec(value); + if (match?.groups) { + return new RegExp(match.groups.pattern, match.groups.flags); + } + return new RegExp(value); + } catch { + return currentType; + } + } + + if (currentType instanceof Map) { + const entries = value + .slice(1, -1) + .split(', ') + .map(entry => { + const [key, val] = entry.split(' => '); + return [parseValue(key, ''), parseValue(val, '')] as [unknown, unknown]; + }); + return new Map(entries); + } + + if (currentType instanceof Set) { + const values = value + .slice(1, -1) + .split(', ') + .map(v => parseValue(v, '')); + return new Set(values); + } + + if (Array.isArray(currentType)) { + return parseArrayValue(value.slice(1, -1)); + } + + if (typeof currentType === 'object') { + const entries = value + .slice(1, -1) + .split(', ') + .map(entry => { + const [key, val] = entry.split(': '); + return [key, parseValue(val, '')]; + }); + return Object.fromEntries(entries); + } + + return value; + } catch { + return currentType; + } +}; + +const detectValueType = (value: string): { + type: 'string' | 'number' | 'undefined' | 'null' | 'boolean'; + value: unknown; +} => { + const trimmed = value.trim(); + + if (/^".*"$/.test(trimmed)) { + return { type: 'string', value: trimmed.slice(1, -1) }; + } + + if (trimmed === 'undefined') return { type: 'undefined', value: undefined }; + if (trimmed === 'null') return { type: 'null', value: null }; + if (trimmed === 'true') return { type: 'boolean', value: true }; + if (trimmed === 'false') return { type: 'boolean', value: false }; + + if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) { + return { type: 'number', value: Number(trimmed) }; + } + + return { type: 'string', value: `"${trimmed}"` }; +}; + +const formatInitialValue = (value: unknown): string => { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + if (typeof value === 'string') return `"${value}"`; + return String(value); +}; + +const EditableValue = ({ value, onSave, onCancel }: EditableValueProps) => { + const inputRef = useRef(null); + const [editValue, setEditValue] = useState(() => { + let initialValue = ''; + try { + if (value instanceof Date) { + initialValue = value.toISOString().slice(0, 16); + } else if (value instanceof Map || value instanceof Set || value instanceof RegExp || + value instanceof Error || value instanceof ArrayBuffer || ArrayBuffer.isView(value) || + (typeof value === 'object' && value !== null)) { + initialValue = formatValue(value); + } else { + initialValue = formatInitialValue(value); + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn(sanitizeErrorMessage(String(error))); + initialValue = String(value); + } + return sanitizeString(initialValue); + }); + + useEffect(() => { + inputRef.current?.focus(); + + if (typeof value === 'string') { + inputRef.current?.setSelectionRange(1, inputRef.current.value.length - 1); + } else { + inputRef.current?.select(); + } + }, [value]); + + const handleChange = useCallback((e: Event) => { + const target = e.target as HTMLInputElement; + if (target) { + setEditValue(target.value); + } + }, []); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + try { + let newValue: unknown; + if (value instanceof Date) { + const date = new Date(editValue); + if (Number.isNaN(date.getTime())) { + throw new Error('Invalid date'); + } + newValue = date; + } else { + const detected = detectValueType(editValue); + newValue = detected.value; + } + onSave(newValue); + } catch { + onCancel(); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onCancel(); + } + }; + + return ( + + ); +}; + +const updateNestedValue = (obj: unknown, path: Array, value: unknown): unknown => { + try { + if (path.length === 0) return value; + + const [key, ...rest] = path; + + if (obj instanceof Map) { + const newMap = new Map(obj); + if (rest.length === 0) { + newMap.set(key, value); + } else { + const currentValue = newMap.get(key); + newMap.set(key, updateNestedValue(currentValue, rest, value)); + } + return newMap; + } + + if (Array.isArray(obj)) { + const index = Number.parseInt(key, 10); + const newArray = [...obj]; + if (rest.length === 0) { + newArray[index] = value; + } else { + newArray[index] = updateNestedValue(obj[index], rest, value); + } + return newArray; + } + + if (obj && typeof obj === 'object') { + if (rest.length === 0) { + return { ...obj, [key]: value }; + } + return { + ...obj, + [key]: updateNestedValue((obj as Record)[key], rest, value) + }; + } + + return value; + } catch { + return obj; + } +}; + +const PropertyElement = ({ + name, + value, + section, + level, + parentPath, + objectPathMap = new WeakMap(), + changedKeys = new Set(), + allowEditing = true, +}: PropertyElementProps) => { + const { fiber } = inspectorState.value; + + const refElement = useRef(null); + const [isExpanded, setIsExpanded] = useState(() => { + const currentPath = getPath( + getDisplayName(fiber?.type) ?? 'Unknown', + section, + parentPath ?? '', + name + ); + return EXPANDED_PATHS.has(currentPath); + }); + const [isEditing, setIsEditing] = useState(false); + + const currentPath = getPath( + getDisplayName(fiber?.type) ?? 'Unknown', + section, + parentPath ?? '', + name + ); + + const prevValue = lastRendered.get(currentPath); + const isChanged = prevValue !== undefined && !isEqual(prevValue, value); + + const renderNestedProperties = useCallback((obj: InspectableValue) => { + let entries: Array; + + if (obj instanceof ArrayBuffer) { + const view = new Uint8Array(obj); + entries = Array.from(view).map((v, i) => [i, v]); + } else if (obj instanceof DataView) { + const view = new Uint8Array(obj.buffer, obj.byteOffset, obj.byteLength); + entries = Array.from(view).map((v, i) => [i, v]); + } else if (ArrayBuffer.isView(obj)) { + if (obj instanceof BigInt64Array || obj instanceof BigUint64Array) { + entries = Array.from({ length: obj.length }, (_, i) => [i, obj[i]]); + } else { + entries = Array.from(obj as ArrayLike).map((v, i) => [i, v]); + } + } else if (obj instanceof Map) { + entries = Array.from(obj.entries()).map(([k, v]) => [String(k), v]); + } else if (obj instanceof Set) { + entries = Array.from(obj).map((v, i) => [i, v]); + } else if (Array.isArray(obj)) { + entries = obj.map((value, index) => [index, value]); + } else { + entries = Object.entries(obj); + } + + const canEditChildren = !(obj instanceof DataView || obj instanceof ArrayBuffer || ArrayBuffer.isView(obj)); + + return entries.map(([key, value]) => ( + + )); + }, [section, level, currentPath, objectPathMap, changedKeys]); + + const valuePreview = useMemo(() => formatValue(value), [value]); + + const { overrideProps, overrideHookState } = getOverrideMethods(); + const canEdit = useMemo(() => { + return allowEditing && ( + section === 'props' + ? !!overrideProps && name !== 'children' + : section === 'state' + ? !!overrideHookState + : false + ); + }, [section, overrideProps, overrideHookState, allowEditing, name]); + + useEffect(() => { + lastRendered.set(currentPath, value); + + const isSameComponentType = lastInspectedFiber?.type === fiber?.type; + const isFirstRender = !lastRendered.has(currentPath); + const shouldFlash = isChanged && + refElement.current && + prevValue !== undefined && + !isSameComponentType && + !isFirstRender; + + if (shouldFlash && refElement.current) { + flashManager.create(refElement.current); + } + + return () => { + if (refElement.current) { + flashManager.cleanup(refElement.current); + } + }; + }, [value, isChanged, currentPath, prevValue, fiber?.type]); + + const shouldShowWarning = useMemo(() => { + const shouldShowChange = !lastRendered.has(currentPath) || !isEqual(lastRendered.get(currentPath), value); + + const isBadRender = level === 0 && + shouldShowChange && + typeof value === 'object' && + value !== null && + !isPromise(value); + + return isBadRender; + }, [level, currentPath, value]); + + const clipboardText = useMemo(() => formatForClipboard(value), [value]); + + const handleToggleExpand = useCallback(() => { + setIsExpanded((state) => { + const newIsExpanded = !state; + if (newIsExpanded) { + EXPANDED_PATHS.add(currentPath); + } else { + EXPANDED_PATHS.delete(currentPath); + } + return newIsExpanded; + }); + }, [currentPath]); + + const handleEdit = useCallback(() => { + if (canEdit) { + setIsEditing(true); + } + }, [canEdit]); + + const handleSave = useCallback((newValue: unknown) => { + if (isEqual(value, newValue)) { + setIsEditing(false); + return; + } + + if (section === 'props' && overrideProps) { + tryOrElse(() => { + if (!fiber) return; + + if (parentPath) { + const parts = parentPath.split('.'); + const path = parts.filter(part => part !== 'props' && part !== getDisplayName(fiber.type)); + path.push(name); + overrideProps(fiber, path, newValue); + } else { + overrideProps(fiber, [name], newValue); + } + }, null); + } + + if (section === 'state' && overrideHookState) { + tryOrElse(() => { + if (!fiber) return; + + if (!parentPath) { + const stateNames = getStateNames(fiber); + const namedStateIndex = stateNames.indexOf(name); + const hookId = namedStateIndex !== -1 ? namedStateIndex.toString() : '0'; + overrideHookState(fiber, hookId, [], newValue); + } else { + const fullPathParts = parentPath.split('.'); + const stateIndex = fullPathParts.indexOf('state'); + if (stateIndex === -1) return; + + const statePath = fullPathParts.slice(stateIndex + 1); + const baseStateKey = statePath[0]; + const stateNames = getStateNames(fiber); + const namedStateIndex = stateNames.indexOf(baseStateKey); + const hookId = namedStateIndex !== -1 ? namedStateIndex.toString() : '0'; + + const currentState = getCurrentState(fiber); + if (!currentState || !(baseStateKey in currentState)) { + // eslint-disable-next-line no-console + console.warn(sanitizeErrorMessage('Invalid state key')); + return; + } + + const updatedState = updateNestedValue(currentState[baseStateKey], statePath.slice(1).concat(name), newValue); + overrideHookState(fiber, hookId, [], updatedState); + } + }, null); + } + + setIsEditing(false); + }, [value, section, overrideProps, overrideHookState, fiber, name, parentPath]); + + const checkCircularInValue = useMemo((): boolean => { + if (!value || typeof value !== 'object' || isPromise(value)) return false; + + return 'type' in value && value.type === 'circular'; + }, [value]); + + if (checkCircularInValue) { + return ( +
+
+
+
{name}:
+ [Circular Reference] +
+
+
+ ); + } + + return ( +
+
+ { + isExpandable(value) && ( + + ) + } +
+ { + shouldShowWarning && ( + + ) + } +
{name}:
+ { + isEditing && isEditableValue(value, parentPath) + ? ( + setIsEditing(false)} + /> + ) + : ( + + ) + } + + {({ ClipboardIcon }) => <>{ClipboardIcon}} + +
+ { + isExpandable(value) && isExpanded && ( +
+ {renderNestedProperties(value)} +
+ ) + } +
+
+ ); +}; + +const PropertySection = ({ title, section }: PropertySectionProps) => { + const { current, changes } = inspectorState.value; + + const pathMap = useMemo(() => new WeakMap>(), []); + const changedKeys = useMemo(() => { + switch (section) { + case 'props': + return changes.props; + case 'state': + return changes.state; + case 'context': + return changes.context; + default: + return new Set(); + } + }, [section, changes]); + + const currentData = useMemo(() => { + let result: Record | undefined; + switch (section) { + case 'props': + result = current.props; + break; + case 'state': + result = current.state; + break; + case 'context': + result = current.context; + break; + } + return result || {}; + }, [section, current.props, current.state, current.context]); + + if (!currentData || Object.keys(currentData).length === 0) { + return null; + } + + return ( +
+
{title}
+ { + Object.entries(currentData).map(([key, value]) => ( + + )) + } +
+ ); +}; + +const WhatChanged = memo(() => { + const [isExpanded, setIsExpanded] = useState(Store.wasDetailsOpen.value); + const { changes } = inspectorState.value; + + const hasChanges = changes.state.size > 0 || changes.props.size > 0 || changes.context.size > 0; + if (!hasChanges) { + return null; + } + + const handleToggle = useCallback(() => { + setIsExpanded((state) => { + Store.wasDetailsOpen.value = !state; + return !state; + }); + }, []); + + return ( + + ); +}); + +export const Inspector = memo(() => { + useEffect(() => { + let rafId: ReturnType; + let debounceTimer: ReturnType; + let lastUpdateTime = 0; + let isProcessing = false; + let pendingFiber: Fiber | null = null; + + const updateInspectorState = (fiber: Fiber) => { + const isNewComponent = !lastInspectedFiber || lastInspectedFiber.type !== fiber.type; + if (isNewComponent) { + resetStateTracking(); + } + + inspectorState.value = { + fiber, + changes: { + props: getChangedProps(fiber), + state: getChangedState(fiber), + context: getChangedContext(fiber) + }, + current: { + state: getCurrentState(fiber), + props: getCurrentProps(fiber), + context: getCurrentContext(fiber) + } + }; + + lastInspectedFiber = fiber; + }; + + const processFiberUpdate = (fiber: Fiber) => { + const now = Date.now(); + const timeSinceLastUpdate = now - lastUpdateTime; + + clearTimeout(debounceTimer); + cancelAnimationFrame(rafId); + + if (timeSinceLastUpdate < THROTTLE_MS) { + pendingFiber = fiber; + debounceTimer = setTimeout(() => { + rafId = requestAnimationFrame(() => { + if (pendingFiber) { + isProcessing = true; + updateInspectorState(pendingFiber); + isProcessing = false; + pendingFiber = null; + lastUpdateTime = Date.now(); + } + }); + }, DEBOUNCE_MS); + return; + } + + rafId = requestAnimationFrame(() => { + isProcessing = true; + updateInspectorState(fiber); + isProcessing = false; + lastUpdateTime = now; + }); + }; + + const unSubState = Store.inspectState.subscribe((state) => { + if (state.kind !== 'focused' || !state.focusedDomElement) return; + + const { parentCompositeFiber } = getCompositeComponentFromElement(state.focusedDomElement); + if (!parentCompositeFiber) return; + + processFiberUpdate(parentCompositeFiber); + }); + + const unSubReport = Store.lastReportTime.subscribe(() => { + if (isProcessing) return; + + const inspectState = Store.inspectState.value; + if (inspectState.kind !== 'focused') return; + + const element = inspectState.focusedDomElement; + const { parentCompositeFiber } = getCompositeComponentFromElement(element); + + if (parentCompositeFiber && lastInspectedFiber) { + processFiberUpdate(parentCompositeFiber); + } + }); + + return () => { + unSubState(); + unSubReport(); + clearTimeout(debounceTimer); + cancelAnimationFrame(rafId); + pendingFiber = null; + }; + }, []); + + return ( + +
+ + + + +
+
+ ); +}); + +export const replayComponent = async (fiber: Fiber): Promise => { + try { + const { overrideProps, overrideHookState } = getOverrideMethods(); + if (!overrideProps || !overrideHookState || !fiber) return; + + const currentProps = fiber.memoizedProps || {}; + for (const key of Object.keys(currentProps)) { + try { + overrideProps(fiber, [key], currentProps[key]); + } catch { + // Silently ignore prop override errors + } + } + + const state = getCurrentState(fiber) ?? {}; + for (const key of Object.keys(state)) { + try { + const stateNames = getStateNames(fiber); + const namedStateIndex = stateNames.indexOf(key); + const hookId = namedStateIndex !== -1 ? namedStateIndex.toString() : '0'; + overrideHookState(fiber, hookId, [], state[key]); + } catch { + // Silently ignore state override errors + } + } + + let child = fiber.child; + while (child) { + await replayComponent(child); + child = child.sibling; + } + } catch { + // Silently ignore replay errors + } +}; diff --git a/packages/scan/src/web/components/inspector/overlay/index.tsx b/packages/scan/src/web/components/inspector/overlay/index.tsx new file mode 100644 index 0000000..478b280 --- /dev/null +++ b/packages/scan/src/web/components/inspector/overlay/index.tsx @@ -0,0 +1,613 @@ +import { useRef, useEffect } from 'preact/hooks'; +import { getDisplayName, type Fiber } from 'bippy'; +import { cn, throttle } from '~web/utils/helpers'; +import { Store, ReactScanInternals } from '~core/index'; +import { + getCompositeComponentFromElement, + findComponentDOMNode, + type States, +} from '~web/components/inspector/utils'; + +type DrawKind = 'locked' | 'inspecting'; + +interface Rect { + left: number; + top: number; + width: number; + height: number; +} + +interface LockIconRect { + x: number; + y: number; + width: number; + height: number; +} + +const ANIMATION_CONFIG = { + frameInterval: 1000 / 60, + speeds: { + fast: 0.51, + slow: 0.1, + off: 0, + } +} as const; + +export const OVERLAY_DPR = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; + +export const currentLockIconRect: LockIconRect | null = null; + +export const ScanOverlay = () => { + const refCanvas = useRef(null); + const refEventCatcher = useRef(null); + const refCurrentRect = useRef(null); + const refCurrentLockIconRect = useRef(null); + const refLastHoveredElement = useRef(null); + const refRafId = useRef(0); + const refTimeout = useRef(); + const refCleanupMap = useRef(new Map void>()); + const refIsFadingOut = useRef(false); + const refLastFrameTime = useRef(0); + + const linearInterpolation = (start: number, end: number, t: number) => { + return start * (1 - t) + end * t; + }; + + const drawLockIcon = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + ) => { + ctx.save(); + ctx.strokeStyle = 'white'; + ctx.fillStyle = 'white'; + ctx.lineWidth = 1.5; + + const shackleWidth = size * 0.6; + const shackleHeight = size * 0.5; + const shackleX = x + (size - shackleWidth) / 2; + const shackleY = y; + + ctx.beginPath(); + ctx.arc( + shackleX + shackleWidth / 2, + shackleY + shackleHeight / 2, + shackleWidth / 2, + Math.PI, + 0, + false, + ); + ctx.stroke(); + + const bodyWidth = size * 0.8; + const bodyHeight = size * 0.5; + const bodyX = x + (size - bodyWidth) / 2; + const bodyY = y + shackleHeight / 2; + + ctx.fillRect(bodyX, bodyY, bodyWidth, bodyHeight); + ctx.restore(); + }; + + const drawStatsPill = ( + ctx: CanvasRenderingContext2D, + rect: Rect, + kind: 'locked' | 'inspecting', + fiber: Fiber | null, + ) => { + if (!fiber) return; + + const reportDataFiber = Store.reportData.get(fiber) ?? + (fiber.alternate ? Store.reportData.get(fiber.alternate) : null); + + const stats = { + count: reportDataFiber?.count ?? 0, + time: reportDataFiber?.time ?? 0, + }; + + const pillHeight = 24; + const pillPadding = 8; + const componentName = getDisplayName(fiber?.type) ?? 'Unknown'; + let text = componentName; + if (stats.count) { + text += ` • ×${stats.count}`; + if (stats.time) { + text += ` (${stats.time.toFixed(1)}ms)`; + } + } + + ctx.save(); + ctx.font = '12px system-ui, -apple-system, sans-serif'; + const textMetrics = ctx.measureText(text); + const textWidth = textMetrics.width; + const lockIconSize = kind === 'locked' ? 14 : 0; + const lockIconPadding = kind === 'locked' ? 6 : 0; + const pillWidth = textWidth + pillPadding * 2 + lockIconSize + lockIconPadding; + + const pillX = rect.left; + const pillY = rect.top - pillHeight - 4; + + ctx.fillStyle = 'rgb(37, 37, 38, .75)'; + ctx.beginPath(); + ctx.roundRect(pillX, pillY, pillWidth, pillHeight, 3); + ctx.fill(); + + if (kind === 'locked') { + const lockX = pillX + pillPadding; + const lockY = pillY + (pillHeight - lockIconSize) / 2 + 2; + drawLockIcon(ctx, lockX, lockY, lockIconSize); + refCurrentLockIconRect.current = { + x: lockX, + y: lockY, + width: lockIconSize, + height: lockIconSize, + }; + } else { + refCurrentLockIconRect.current = null; + } + + ctx.fillStyle = 'white'; + ctx.textBaseline = 'middle'; + const textX = pillX + pillPadding + (kind === 'locked' ? lockIconSize + lockIconPadding : 0); + ctx.fillText(text, textX, pillY + pillHeight / 2); + ctx.restore(); + }; + + const drawRect = ( + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + kind: DrawKind, + fiber: Fiber | null, + ) => { + if (!refCurrentRect.current) return; + const rect = refCurrentRect.current; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + ctx.strokeStyle = 'rgba(142, 97, 227, 0.5)'; + ctx.fillStyle = 'rgba(173, 97, 230, 0.10)'; + + if (kind === 'locked') { + ctx.setLineDash([]); + } else { + ctx.setLineDash([4]); + } + + ctx.lineWidth = 1; + ctx.fillRect(rect.left, rect.top, rect.width, rect.height); + ctx.strokeRect(rect.left, rect.top, rect.width, rect.height); + + drawStatsPill(ctx, rect, kind, fiber); + }; + + const animate = ( + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + targetRect: Rect, + kind: DrawKind, + parentCompositeFiber: Fiber, + onComplete?: () => void, + ) => { + const speed = ReactScanInternals.options.value.animationSpeed as keyof typeof ANIMATION_CONFIG.speeds; + const t = ANIMATION_CONFIG.speeds[speed] ?? ANIMATION_CONFIG.speeds.off; + + const animationFrame = (timestamp: number) => { + if (timestamp - refLastFrameTime.current < ANIMATION_CONFIG.frameInterval) { + refRafId.current = requestAnimationFrame(animationFrame); + return; + } + refLastFrameTime.current = timestamp; + + if (!refCurrentRect.current) { + cancelAnimationFrame(refRafId.current); + return; + } + + refCurrentRect.current = { + left: linearInterpolation(refCurrentRect.current.left, targetRect.left, t), + top: linearInterpolation(refCurrentRect.current.top, targetRect.top, t), + width: linearInterpolation(refCurrentRect.current.width, targetRect.width, t), + height: linearInterpolation(refCurrentRect.current.height, targetRect.height, t), + }; + + drawRect(canvas, ctx, kind, parentCompositeFiber); + + const stillMoving = + Math.abs(refCurrentRect.current.left - targetRect.left) > 0.1 || + Math.abs(refCurrentRect.current.top - targetRect.top) > 0.1 || + Math.abs(refCurrentRect.current.width - targetRect.width) > 0.1 || + Math.abs(refCurrentRect.current.height - targetRect.height) > 0.1; + + if (stillMoving) { + refRafId.current = requestAnimationFrame(animationFrame); + } else { + refCurrentRect.current = targetRect; + drawRect(canvas, ctx, kind, parentCompositeFiber); + cancelAnimationFrame(refRafId.current); + ctx.restore(); + onComplete?.(); + } + }; + + cancelAnimationFrame(refRafId.current); + clearTimeout(refTimeout.current); + + refRafId.current = requestAnimationFrame(animationFrame); + + refTimeout.current = setTimeout(() => { + cancelAnimationFrame(refRafId.current); + refCurrentRect.current = targetRect; + drawRect(canvas, ctx, kind, parentCompositeFiber); + ctx.restore(); + onComplete?.(); + }, 1000); + }; + + const setupOverlayAnimation = ( + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + targetRect: Rect, + kind: DrawKind, + parentCompositeFiber: Fiber, + ) => { + ctx.save(); + + if (!refCurrentRect.current) { + refCurrentRect.current = targetRect; + drawRect(canvas, ctx, kind, parentCompositeFiber); + ctx.restore(); + return; + } + + animate(canvas, ctx, targetRect, kind, parentCompositeFiber); + }; + + const drawHoverOverlay = ( + overlayElement: HTMLElement | null, + canvas: HTMLCanvasElement | null, + ctx: CanvasRenderingContext2D | null, + kind: DrawKind, + ) => { + if (!overlayElement || !canvas || !ctx) return; + + const { parentCompositeFiber, targetRect } = getCompositeComponentFromElement(overlayElement); + if (!parentCompositeFiber || !targetRect) return; + + setupOverlayAnimation(canvas, ctx, targetRect, kind, parentCompositeFiber); + }; + + const unsubscribeAll = () => { + for (const cleanup of refCleanupMap.current.values()) { + cleanup?.(); + } + }; + + const cleanupCanvas = (canvas: HTMLCanvasElement) => { + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + refCurrentRect.current = null; + refCurrentLockIconRect.current = null; + refLastHoveredElement.current = null; + canvas.classList.remove('fade-in'); + refIsFadingOut.current = false; + }; + + const startFadeOut = (onComplete?: () => void) => { + if (!refCanvas.current || refIsFadingOut.current) return; + + const handleTransitionEnd = (e: TransitionEvent) => { + if (!refCanvas.current || e.propertyName !== 'opacity' || !refIsFadingOut.current) return; + refCanvas.current.removeEventListener('transitionend', handleTransitionEnd); + cleanupCanvas(refCanvas.current); + onComplete?.(); + }; + + const existingListener = refCleanupMap.current.get('fade-out'); + if (existingListener) { + existingListener(); + refCleanupMap.current.delete('fade-out'); + } + + refCanvas.current.addEventListener('transitionend', handleTransitionEnd); + refCleanupMap.current.set('fade-out', () => { + refCanvas.current?.removeEventListener('transitionend', handleTransitionEnd); + }); + + refIsFadingOut.current = true; + refCanvas.current.classList.remove('fade-in'); + refCanvas.current.classList.add('fade-out'); + }; + + const startFadeIn = () => { + if (!refCanvas.current) return; + refCanvas.current.classList.remove('fade-out'); + refCanvas.current.classList.add('fade-in'); + refIsFadingOut.current = false; + }; + + const handleHoverableElement = (componentElement: HTMLElement) => { + if (componentElement === refLastHoveredElement.current) return; + + startFadeIn(); + refLastHoveredElement.current = componentElement; + Store.inspectState.value = { + kind: 'inspecting', + hoveredDomElement: componentElement, + }; + }; + + const handleNonHoverableArea = () => { + if (!refCurrentRect.current || !refCanvas.current || refIsFadingOut.current) return; + startFadeOut(); + }; + + const handleMouseMove = throttle((e: MouseEvent) => { + const state = Store.inspectState.peek(); + if (state.kind !== 'inspecting' || !refEventCatcher.current) return; + + const element = document.elementFromPoint(e.clientX, e.clientY); + clearTimeout(refTimeout.current); + + if (element && element !== refCanvas.current) { + const { parentCompositeFiber } = getCompositeComponentFromElement(element as HTMLElement); + if (parentCompositeFiber) { + const componentElement = findComponentDOMNode(parentCompositeFiber); + if (componentElement) { + handleHoverableElement(componentElement); + return; + } + } + } + + handleNonHoverableArea(); + }, 32); + + const isClickInLockIcon = (e: MouseEvent, canvas: HTMLCanvasElement) => { + const currentRect = refCurrentLockIconRect.current; + if (!currentRect) return false; + + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const x = (e.clientX - rect.left) * scaleX; + const y = (e.clientY - rect.top) * scaleY; + const adjustedX = x / OVERLAY_DPR; + const adjustedY = y / OVERLAY_DPR; + + return ( + adjustedX >= currentRect.x && + adjustedX <= currentRect.x + currentRect.width && + adjustedY >= currentRect.y && + adjustedY <= currentRect.y + currentRect.height + ); + }; + + const handleLockIconClick = (state: States) => { + if (state.kind === 'focused') { + Store.inspectState.value = { + kind: 'inspecting', + hoveredDomElement: state.focusedDomElement, + }; + } + }; + + const handleElementClick = (e: MouseEvent) => { + const element = refLastHoveredElement.current ?? document.elementFromPoint(e.clientX, e.clientY); + if (!element) return; + + const { parentCompositeFiber } = getCompositeComponentFromElement(element as HTMLElement); + if (!parentCompositeFiber) return; + + const componentElement = findComponentDOMNode(parentCompositeFiber); + if (!componentElement) { + refLastHoveredElement.current = null; + Store.inspectState.value = { + kind: 'inspect-off', + }; + return; + } + + Store.inspectState.value = { + kind: 'focused', + focusedDomElement: componentElement, + }; + }; + + const handleClick = (e: MouseEvent) => { + const state = Store.inspectState.peek(); + const canvas = refCanvas.current; + if (!canvas || !refEventCatcher.current) return; + + if (isClickInLockIcon(e, canvas)) { + e.preventDefault(); + e.stopPropagation(); + handleLockIconClick(state); + return; + } + + if (state.kind === 'inspecting') { + e.preventDefault(); + e.stopPropagation(); + handleElementClick(e); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + const state = Store.inspectState.peek(); + const canvas = refCanvas.current; + if (!canvas || e.key !== 'Escape') return; + + switch (state.kind) { + case 'focused': { + startFadeIn(); + refCurrentRect.current = null; + refLastHoveredElement.current = state.focusedDomElement; + Store.inspectState.value = { + kind: 'inspecting', + hoveredDomElement: state.focusedDomElement, + }; + break; + } + case 'inspecting': { + startFadeOut(() => { + Store.inspectState.value = { + kind: 'inspect-off', + }; + }); + break; + } + } + }; + + const handleStateChange = ( + state: States, + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + ) => { + refCleanupMap.current.get(state.kind)?.(); + + if (refEventCatcher.current) { + if (state.kind === 'inspecting') { + refEventCatcher.current.style.pointerEvents = 'none'; + } else { + refEventCatcher.current.style.removeProperty('pointer-events'); + } + } + + if (refRafId.current) { + cancelAnimationFrame(refRafId.current); + } + + let unsubReport: (() => void) | undefined; + + switch (state.kind) { + case 'inspect-off': + return; + + case 'inspecting': + drawHoverOverlay(state.hoveredDomElement, canvas, ctx, 'inspecting'); + break; + + case 'focused': + if (!state.focusedDomElement) return; + + if (refLastHoveredElement.current !== state.focusedDomElement) { + refLastHoveredElement.current = state.focusedDomElement; + } + + drawHoverOverlay(state.focusedDomElement, canvas, ctx, 'locked'); + + unsubReport = Store.lastReportTime.subscribe(() => { + if (refRafId.current && refCurrentRect.current) { + const { parentCompositeFiber } = getCompositeComponentFromElement(state.focusedDomElement); + if (parentCompositeFiber) { + drawHoverOverlay(state.focusedDomElement, canvas, ctx, 'locked'); + } + } + }); + + if (unsubReport) { + refCleanupMap.current.set(state.kind, unsubReport); + } + break; + } + }; + + const updateCanvasSize = (canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => { + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * OVERLAY_DPR; + canvas.height = rect.height * OVERLAY_DPR; + ctx.scale(OVERLAY_DPR, OVERLAY_DPR); + ctx.save(); + }; + + const handleResizeOrScroll = () => { + const state = Store.inspectState.peek(); + const canvas = refCanvas.current; + const ctx = canvas?.getContext('2d'); + if (!canvas || !ctx) return; + + cancelAnimationFrame(refRafId.current); + clearTimeout(refTimeout.current); + + updateCanvasSize(canvas, ctx); + refCurrentRect.current = null; + + if (state.kind === 'focused' && state.focusedDomElement) { + drawHoverOverlay(state.focusedDomElement, canvas, ctx, 'locked'); + } else if (state.kind === 'inspecting' && state.hoveredDomElement) { + drawHoverOverlay(state.hoveredDomElement, canvas, ctx, 'inspecting'); + } + }; + + const handlePointerDown = (e: PointerEvent) => { + const state = Store.inspectState.peek(); + const canvas = refCanvas.current; + if (!canvas) return; + + if (state.kind === 'inspecting' || isClickInLockIcon(e as unknown as MouseEvent, canvas)) { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + } + }; + + // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + useEffect(() => { + const canvas = refCanvas.current; + const ctx = canvas?.getContext('2d'); + if (!canvas || !ctx) return; + + updateCanvasSize(canvas, ctx); + + const unSubState = Store.inspectState.subscribe((state) => { + handleStateChange(state, canvas, ctx); + }); + + window.addEventListener('scroll', handleResizeOrScroll, { passive: true }); + window.addEventListener('resize', handleResizeOrScroll, { passive: true }); + document.addEventListener('mousemove', handleMouseMove, { passive: true, capture: true }); + document.addEventListener('pointerdown', handlePointerDown, { capture: true }); + document.addEventListener('click', handleClick, { capture: true }); + window.addEventListener('keydown', handleKeyDown); + + return () => { + unsubscribeAll(); + unSubState(); + window.removeEventListener('scroll', handleResizeOrScroll); + window.removeEventListener('resize', handleResizeOrScroll); + document.removeEventListener('mousemove', handleMouseMove, { capture: true }); + document.removeEventListener('click', handleClick, { capture: true }); + document.removeEventListener('pointerdown', handlePointerDown, { capture: true }); + window.removeEventListener('keydown', handleKeyDown); + + if (refRafId.current) { + cancelAnimationFrame(refRafId.current); + } + clearTimeout(refTimeout.current); + }; + }, []); + + return ( + <> +
+ + + ); +}; diff --git a/packages/scan/src/web/components/inspector/overlay/utils.ts b/packages/scan/src/web/components/inspector/overlay/utils.ts new file mode 100644 index 0000000..82ea0ef --- /dev/null +++ b/packages/scan/src/web/components/inspector/overlay/utils.ts @@ -0,0 +1,460 @@ +import { FunctionComponentTag, type Fiber } from 'bippy'; +import type { ComponentState } from 'react'; +import { isEqual } from '~core/utils'; + +interface ContextDependency { + context: ReactContext; + next: ContextDependency | null; +} + +interface ContextValue { + displayValue: Record; + rawValue?: unknown; + isUserContext?: boolean; +} + +interface ReactContext { + $$typeof: symbol; + Consumer: ReactContext; + Provider: { + $$typeof: symbol; + _context: ReactContext; + }; + _currentValue: T; + _currentValue2: T; + displayName?: string; +} + +const stateChangeCounts = new Map(); +const propsChangeCounts = new Map(); +const contextChangeCounts = new Map(); +let lastRenderedStates = new WeakMap(); + +const STATE_NAME_REGEX = /\[(?\w+),\s*set\w+\]/g; +const PROPS_ORDER_REGEX = /\(\s*{\s*(?[^}]+)\s*}\s*\)/; + +const ensureRecord = ( + value: unknown, + seen = new WeakSet(), +): Record => { + if (value === null || value === undefined) { + return {}; + } + + if (value instanceof Element) { + return { + type: 'Element', + tagName: value.tagName.toLowerCase(), + }; + } + + if (typeof value === 'function') { + return { type: 'function', name: value.name || 'anonymous' }; + } + + if ( + value && + (value instanceof Promise || (typeof value === 'object' && 'then' in value)) + ) { + return { type: 'promise' }; + } + + if (typeof value === 'object') { + if (seen.has(value)) { + return { type: 'circular' }; + } + + if (Array.isArray(value)) { + seen.add(value); + const safeArray = value.map((item) => ensureRecord(item, seen)); + return { type: 'array', length: value.length, items: safeArray }; + } + + seen.add(value); + + const result: Record = {}; + try { + const keys = Object.keys(value); + for (const key of keys) { + try { + const val = value[key]; + result[key] = ensureRecord(val, seen); + } catch { + result[key] = { type: 'error', message: 'Failed to access property' }; + } + } + return result; + } catch { + return { type: 'object' }; + } + } + + return { value }; +}; + +export const resetStateTracking = () => { + stateChangeCounts.clear(); + propsChangeCounts.clear(); + contextChangeCounts.clear(); + lastRenderedStates = new WeakMap(); +}; + +export const getStateChangeCount = (name: string): number => + stateChangeCounts.get(name) ?? 0; +export const getPropsChangeCount = (name: string): number => + propsChangeCounts.get(name) ?? 0; +export const getContextChangeCount = (name: string): number => + contextChangeCounts.get(name) ?? 0; + +export const getStateNames = (fiber: Fiber): Array => { + const componentSource = fiber.type?.toString?.() || ''; + // Return the matches if we found any, otherwise return empty array + // Empty array means we'll use numeric indices as fallback + return componentSource + ? Array.from( + componentSource.matchAll(STATE_NAME_REGEX), + (m: RegExpMatchArray) => m.groups?.name ?? '', + ) + : []; +}; + +export const isDirectComponent = (fiber: Fiber): boolean => { + if (!fiber || !fiber.type) return false; + + const isFunctionalComponent = typeof fiber.type === 'function'; + const isClassComponent = fiber.type?.prototype?.isReactComponent ?? false; + + if (!(isFunctionalComponent || isClassComponent)) return false; + + if (isClassComponent) { + return true; + } + + let memoizedState = fiber.memoizedState; + while (memoizedState) { + if (memoizedState.queue) { + return true; + } + memoizedState = memoizedState.next; + } + + return false; +}; + +export const getCurrentState = (fiber: Fiber | null) => { + if (!fiber) return {}; + + try { + if (fiber.tag === FunctionComponentTag && isDirectComponent(fiber)) { + return getCurrentFiberState(fiber); + } + } catch { + // Silently fail + } + return {}; +}; + +export const getChangedState = (fiber: Fiber): Set => { + const changes = new Set(); + if (!fiber || fiber.tag !== FunctionComponentTag || !isDirectComponent(fiber)) + return changes; + + try { + const currentState = getCurrentFiberState(fiber); + if (!currentState) return changes; + + if (!fiber.alternate) { + lastRenderedStates.set(fiber, { ...currentState }); + return changes; + } + + const lastState = lastRenderedStates.get(fiber); + if (lastState) { + for (const name of Object.keys(currentState)) { + if (!isEqual(currentState[name], lastState[name])) { + changes.add(name); + if (lastState[name] !== undefined) { + const existingCount = stateChangeCounts.get(name) ?? 0; + stateChangeCounts.set(name, existingCount + 1); + } + } + } + } + + lastRenderedStates.set(fiber, { ...currentState }); + if (fiber.alternate) { + lastRenderedStates.set(fiber.alternate, { ...currentState }); + } + } catch { + // Silently fail + } + + return changes; +}; + +const getCurrentFiberState = (fiber: Fiber): ComponentState | null => { + if (fiber.tag !== FunctionComponentTag || !isDirectComponent(fiber)) { + return null; + } + + const currentIsNewer = fiber.alternate + ? (fiber.actualStartTime ?? 0) > (fiber.alternate.actualStartTime ?? 0) + : true; + + let memoizedState = currentIsNewer + ? fiber.memoizedState + : (fiber.alternate?.memoizedState ?? fiber.memoizedState); + + if (!memoizedState) return null; + + const currentState: ComponentState = {}; + const stateNames = getStateNames(fiber); + let index = 0; + + while (memoizedState) { + if (memoizedState.queue) { + const name = stateNames[index] || `{${index}}`; + try { + currentState[name] = getStateValue(memoizedState); + } catch { + // Silently fail + } + index++; + } + memoizedState = memoizedState.next; + } + + return currentState; +}; + +interface MemoizedState { + memoizedState: unknown; + next: MemoizedState | null; + queue?: { + lastRenderedState: unknown; + }; +} + +const getStateValue = (memoizedState: MemoizedState): unknown => { + if (!memoizedState) return undefined; + + if (memoizedState.queue) { + return memoizedState.queue.lastRenderedState; + } + + return memoizedState.memoizedState; +}; + +export const getPropsOrder = (fiber: Fiber): Array => { + const componentSource = fiber.type?.toString?.() || ''; + const match = componentSource.match(PROPS_ORDER_REGEX); + if (!match?.groups?.props) return []; + + return match.groups.props + .split(',') + .map((prop: string) => prop.trim().split(':')[0].split('=')[0].trim()) + .filter(Boolean); +}; + +export const getCurrentProps = (fiber: Fiber): Record => { + const currentIsNewer = fiber?.alternate + ? (fiber.actualStartTime ?? 0) > (fiber.alternate?.actualStartTime ?? 0) + : true; + + const baseProps = currentIsNewer + ? fiber.memoizedProps || fiber.pendingProps + : fiber.alternate?.memoizedProps || + fiber.alternate?.pendingProps || + fiber.memoizedProps; + + return { ...baseProps }; +}; + +export const getChangedProps = (fiber: Fiber): Set => { + const changes = new Set(); + if (!fiber.alternate) return changes; + + const previousProps = fiber.alternate.memoizedProps ?? {}; + const currentProps = fiber.memoizedProps ?? {}; + + const propsOrder = getPropsOrder(fiber); + const orderedProps = [...propsOrder, ...Object.keys(currentProps)]; + const uniqueOrderedProps = [...new Set(orderedProps)]; + + for (const key of uniqueOrderedProps) { + if (key === 'children') continue; + if (!(key in currentProps)) continue; + + const currentValue = currentProps[key]; + const previousValue = previousProps[key]; + + if (!isEqual(currentValue, previousValue)) { + changes.add(key); + + if (typeof currentValue !== 'function') { + const count = (propsChangeCounts.get(key) ?? 0) + 1; + propsChangeCounts.set(key, count); + } + } + } + + for (const key in previousProps) { + if (key === 'children') continue; + if (!(key in currentProps)) { + changes.add(key); + const count = (propsChangeCounts.get(key) ?? 0) + 1; + propsChangeCounts.set(key, count); + } + } + + return changes; +}; + +export const getAllFiberContexts = ( + fiber: Fiber, +): Map => { + const contexts = new Map(); + if (!fiber) return contexts; + + const findProviderValue = ( + contextType: ReactContext, + ): { value: ContextValue; displayName: string } | null => { + let searchFiber: Fiber | null = fiber; + while (searchFiber) { + if (searchFiber.type?.Provider) { + const providerValue = searchFiber.memoizedProps?.value; + const pendingValue = searchFiber.pendingProps?.value; + const currentValue = contextType._currentValue; + + // For built-in contexts + if (contextType.displayName) { + if (currentValue === null) { + return null; + } + return { + value: { + displayValue: ensureRecord(currentValue), + isUserContext: false, + rawValue: currentValue, + }, + displayName: contextType.displayName, + }; + } + + const providerName = + searchFiber.type.name?.replace('Provider', '') ?? + searchFiber._debugOwner?.type?.name ?? + 'Unnamed'; + + const valueToUse = + pendingValue !== undefined + ? pendingValue + : providerValue !== undefined + ? providerValue + : currentValue; + + return { + value: { + displayValue: ensureRecord(valueToUse), + isUserContext: true, + rawValue: valueToUse, + }, + displayName: providerName, + }; + } + searchFiber = searchFiber.return; + } + return null; + }; + + let currentFiber: Fiber | null = fiber; + while (currentFiber) { + if (currentFiber.dependencies?.firstContext) { + let contextItem = currentFiber.dependencies + .firstContext as ContextDependency | null; + while (contextItem !== null) { + const context = contextItem.context; + if (context && '_currentValue' in context) { + const result = findProviderValue(context); + if (result) { + contexts.set(result.displayName, result.value); + } + } + contextItem = contextItem.next; + } + } + currentFiber = currentFiber.return; + } + + return contexts; +}; + +export const getCurrentContext = (fiber: Fiber) => { + const contexts = getAllFiberContexts(fiber); + const contextObj: Record = {}; + + contexts.forEach((value, contextName) => { + contextObj[contextName] = value.displayValue; + }); + + return contextObj; +}; + +const getContextDisplayName = (contextType: unknown): string => { + if (typeof contextType !== 'object' || contextType === null) { + return String(contextType); + } + + const typedContext = contextType as Partial< + ReactContext & { + Provider: { displayName?: string }; + Consumer: { displayName?: string }; + type: { name?: string }; + } + >; + + return ( + typedContext.displayName ?? + typedContext.Provider?.displayName ?? + typedContext.Consumer?.displayName ?? + typedContext.type?.name?.replace('Provider', '') ?? + 'Unnamed' + ); +}; + +export const getChangedContext = (fiber: Fiber): Set => { + const changes = new Set(); + if (!fiber.alternate) return changes; + + const currentContexts = getAllFiberContexts(fiber); + + currentContexts.forEach((_currentValue, contextType) => { + const contextName = getContextDisplayName(contextType); + + let searchFiber: Fiber | null = fiber; + let providerFiber: Fiber | null = null; + + while (searchFiber) { + if (searchFiber.type?.Provider) { + providerFiber = searchFiber; + break; + } + searchFiber = searchFiber.return; + } + + if (providerFiber?.alternate) { + const currentProviderValue = providerFiber.memoizedProps?.value; + const alternateValue = providerFiber.alternate.memoizedProps?.value; + + if (!isEqual(currentProviderValue, alternateValue)) { + changes.add(contextName); + contextChangeCounts.set( + contextName, + (contextChangeCounts.get(contextName) ?? 0) + 1, + ); + } + } + }); + + return changes; +}; diff --git a/packages/scan/src/web/components/inspector/utils.ts b/packages/scan/src/web/components/inspector/utils.ts new file mode 100644 index 0000000..9034b36 --- /dev/null +++ b/packages/scan/src/web/components/inspector/utils.ts @@ -0,0 +1,386 @@ +import { + type Fiber, + getDisplayName, + isCompositeFiber, + isHostFiber, + traverseFiber, +} from 'bippy'; +import { ReactScanInternals } from '~core/index'; +import { isEqual } from '~core/utils'; + +export type States = + | { + kind: 'inspecting'; + hoveredDomElement: HTMLElement | null; + } + | { + kind: 'inspect-off'; + } + | { + kind: 'focused'; + focusedDomElement: HTMLElement; + } + | { + kind: 'uninitialized'; + }; + +interface ReactRootContainer { + _reactRootContainer?: { + _internalRoot?: { + current?: { + child: Fiber; + }; + }; + }; +} + +interface ReactInternalProps { + [key: string]: Fiber; +} + +export const getFiberFromElement = (element: Element): Fiber | null => { + if ('__REACT_DEVTOOLS_GLOBAL_HOOK__' in window) { + const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (!hook.renderers) return null; + for (const [, renderer] of Array.from(hook.renderers)) { + try { + const fiber = renderer.findFiberByHostInstance?.(element); + if (fiber) return fiber; + } catch { + // If React is mid-render, references to previous nodes may disappear + } + } + } + + if ('_reactRootContainer' in element) { + const elementWithRoot = element as unknown as ReactRootContainer; + const rootContainer = elementWithRoot._reactRootContainer; + return rootContainer?._internalRoot?.current?.child ?? null; + } + + for (const key in element) { + if ( + key.startsWith('__reactInternalInstance$') || + key.startsWith('__reactFiber') + ) { + const elementWithFiber = element as unknown as ReactInternalProps; + return elementWithFiber[key]; + } + } + return null; +}; + +export const getFirstStateNode = (fiber: Fiber): Element | null => { + let current: Fiber | null = fiber; + while (current) { + if (current.stateNode instanceof Element) { + return current.stateNode; + } + + if (!current.child) { + break; + } + current = current.child; + } + + while (current) { + if (current.stateNode instanceof Element) { + return current.stateNode; + } + + if (!current.return) { + break; + } + current = current.return; + } + return null; +}; + +export const getNearestFiberFromElement = ( + element: Element | null, +): Fiber | null => { + if (!element) return null; + + try { + const fiber = getFiberFromElement(element); + if (!fiber) return null; + + const res = getParentCompositeFiber(fiber); + return res ? res[0] : null; + } catch { + return null; + } +}; + +export const getParentCompositeFiber = (fiber: Fiber) => { + let curr: Fiber | null = fiber; + let prevNonHost = null; + + while (curr) { + if (isCompositeFiber(curr)) { + return [curr, prevNonHost] as const; + } + if (isHostFiber(curr)) { + prevNonHost = curr; + } + curr = curr.return; + } +}; + +const isFiberInTree = (fiber: Fiber, root: Fiber): boolean => { + return !!traverseFiber(root, (searchFiber) => searchFiber === fiber); +}; + +export const isCurrentTree = (fiber: Fiber) => { + let curr: Fiber | null = fiber; + let rootFiber: Fiber | null = null; + + while (curr) { + if ( + curr.stateNode && + ReactScanInternals.instrumentation?.fiberRoots.has(curr.stateNode) + ) { + rootFiber = curr; + break; + } + curr = curr.return; + } + + if (!rootFiber) { + return false; + } + + const fiberRoot = rootFiber.stateNode; + const currentRootFiber = fiberRoot.current; + + return isFiberInTree(fiber, currentRootFiber); +}; + +export const getCompositeComponentFromElement = (element: Element) => { + const associatedFiber = getNearestFiberFromElement(element); + + if (!associatedFiber) return {}; + const currentAssociatedFiber = isCurrentTree(associatedFiber) + ? associatedFiber + : (associatedFiber.alternate ?? associatedFiber); + const stateNode = getFirstStateNode(currentAssociatedFiber); + if (!stateNode) return {}; + const targetRect = stateNode.getBoundingClientRect(); // causes reflow, be careful + if (!targetRect) return {}; + const anotherRes = getParentCompositeFiber(currentAssociatedFiber); + if (!anotherRes) { + return {}; + } + let [parentCompositeFiber] = anotherRes; + parentCompositeFiber = + (isCurrentTree(parentCompositeFiber) + ? parentCompositeFiber + : parentCompositeFiber.alternate) ?? parentCompositeFiber; + + return { + parentCompositeFiber, + targetRect, + }; +}; + +interface PropChange { + name: string; + value: unknown; + prevValue?: unknown; +} + +export const getChangedPropsDetailed = (fiber: Fiber): Array => { + const currentProps = fiber.memoizedProps ?? {}; + const previousProps = fiber.alternate?.memoizedProps ?? {}; + const changes: Array = []; + + for (const key in currentProps) { + if (key === 'children') continue; + + const currentValue = currentProps[key]; + const prevValue = previousProps[key]; + + if (!isEqual(currentValue, prevValue)) { + changes.push({ + name: key, + value: currentValue, + prevValue, + }); + } + } + + return changes; +}; + +interface OverrideMethods { + overrideProps: + | ((fiber: Fiber, path: Array, value: unknown) => void) + | null; + overrideHookState: + | ((fiber: Fiber, id: string, path: Array, value: unknown) => void) + | null; +} + +const isRecord = (value: unknown): value is Record => { + return value !== null && typeof value === 'object'; +}; + +export const getOverrideMethods = (): OverrideMethods => { + let overrideProps = null; + let overrideHookState = null; + + if ('__REACT_DEVTOOLS_GLOBAL_HOOK__' in window) { + const { renderers } = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (renderers) { + for (const [_, renderer] of Array.from(renderers)) { + try { + if (overrideHookState) { + const prevOverrideHookState = overrideHookState; + overrideHookState = ( + fiber: Fiber, + id: string, + path: unknown[], + value: unknown, + ) => { + // Find the hook + let current = fiber.memoizedState; + for (let i = 0; i < Number(id); i++) { + current = current.next; + } + + if (current?.queue) { + // Update through React's queue mechanism + const queue = current.queue; + if (isRecord(queue) && 'dispatch' in queue) { + const dispatch = queue.dispatch as (value: unknown) => void; + dispatch(value); + return; + } + } + + // Fallback to direct override if queue dispatch isn't available + prevOverrideHookState(fiber, id, path, value); + renderer.overrideHookState?.(fiber, id, path, value); + }; + } else if (renderer.overrideHookState) { + overrideHookState = renderer.overrideHookState.bind(renderer); + } + + if (overrideProps) { + const prevOverrideProps = overrideProps; + overrideProps = ( + fiber: Fiber, + path: Array, + value: unknown, + ) => { + prevOverrideProps(fiber, path, value); + renderer.overrideProps?.(fiber, path, value); + }; + } else if (renderer.overrideProps) { + overrideProps = renderer.overrideProps.bind(renderer); + } + } catch { + /**/ + } + } + } + } + + return { overrideProps, overrideHookState }; +}; + +const nonVisualTags = new Set([ + 'html', + 'meta', + 'script', + 'link', + 'style', + 'head', + 'title', + 'noscript', + 'base', + 'template', + 'iframe', + 'embed', + 'object', + 'param', + 'source', + 'track', + 'area', + 'portal', + 'slot', + 'xml', + 'doctype', + 'comment', +]); +export const findComponentDOMNode = ( + fiber: Fiber, + excludeNonVisualTags = true, +): HTMLElement | null => { + if (fiber.stateNode && 'nodeType' in fiber.stateNode) { + const element = fiber.stateNode as HTMLElement; + if ( + excludeNonVisualTags && + nonVisualTags.has(element.tagName.toLowerCase()) + ) { + return null; + } + return element; + } + + let child = fiber.child; + while (child) { + const result = findComponentDOMNode(child, excludeNonVisualTags); + if (result) return result; + child = child.sibling; + } + + return null; +}; + +export interface InspectableElement { + element: HTMLElement; + depth: number; + name: string; +} + +export const getInspectableElements = ( + root: HTMLElement = document.body, +): Array => { + const result: Array = []; + + const findInspectableFiber = ( + element: HTMLElement | null, + ): HTMLElement | null => { + if (!element) return null; + const { parentCompositeFiber } = getCompositeComponentFromElement(element); + if (!parentCompositeFiber) return null; + + const componentRoot = findComponentDOMNode(parentCompositeFiber); + return componentRoot === element ? element : null; + }; + + const traverse = (element: HTMLElement, depth = 0) => { + const inspectable = findInspectableFiber(element); + if (inspectable) { + const { parentCompositeFiber } = + getCompositeComponentFromElement(inspectable); + + if (!parentCompositeFiber) return; + + result.push({ + element: inspectable, + depth, + name: getDisplayName(parentCompositeFiber.type) ?? 'Unknown', + }); + } + + // Traverse children first (depth-first) + for (const child of element.children) { + traverse(child as HTMLElement, inspectable ? depth + 1 : depth); + } + }; + + traverse(root); + return result; +}; diff --git a/packages/scan/src/web/components/widget/fps-meter.tsx b/packages/scan/src/web/components/widget/fps-meter.tsx new file mode 100644 index 0000000..4cf0b60 --- /dev/null +++ b/packages/scan/src/web/components/widget/fps-meter.tsx @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'preact/hooks'; +import { getFPS } from '~core/instrumentation'; +import { cn } from '~web/utils/helpers'; + +export const FpsMeter = () => { + const [fps, setFps] = useState(null); + + useEffect(() => { + const intervalId = setInterval(() => { + setFps(getFPS()); + }, 100); + + return () => clearInterval(intervalId); + }, []); + + return ( + + FPS + + ); +}; + +export default FpsMeter; diff --git a/packages/scan/src/web/components/widget/header.tsx b/packages/scan/src/web/components/widget/header.tsx new file mode 100644 index 0000000..9def512 --- /dev/null +++ b/packages/scan/src/web/components/widget/header.tsx @@ -0,0 +1,146 @@ +import { useRef, useEffect } from 'preact/hooks'; +import { getDisplayName } from 'bippy'; +import { Store } from '~core/index'; +import { replayComponent } from '~web/components/inspector'; +import { + getCompositeComponentFromElement, + getOverrideMethods, +} from '../inspector/utils'; +import { Icon } from '../icon'; + +const REPLAY_DELAY_MS = 300; + +export const BtnReplay = () => { + const refTimeout = useRef(); + const replayState = useRef({ + isReplaying: false, + toggleDisabled: (disabled: boolean, button: HTMLElement) => { + button.classList[disabled ? 'add' : 'remove']('disabled'); + }, + }); + + const { overrideProps, overrideHookState } = getOverrideMethods(); + const canEdit = !!overrideProps; + + const handleReplay = (e: MouseEvent) => { + e.stopPropagation(); + const state = replayState.current; + const button = e.currentTarget as HTMLElement; + + const inspectState = Store.inspectState.value; + if (state.isReplaying || inspectState.kind !== 'focused') return; + + const { parentCompositeFiber } = getCompositeComponentFromElement( + inspectState.focusedDomElement, + ); + if (!parentCompositeFiber || !overrideProps || !overrideHookState) return; + + state.isReplaying = true; + state.toggleDisabled(true, button); + + void replayComponent(parentCompositeFiber) + .catch(() => void 0) + .finally(() => { + clearTimeout(refTimeout.current); + if (document.hidden) { + state.isReplaying = false; + state.toggleDisabled(false, button); + } else { + refTimeout.current = setTimeout(() => { + state.isReplaying = false; + state.toggleDisabled(false, button); + }, REPLAY_DELAY_MS); + } + }); + }; + + if (!canEdit) return null; + + return ( + + ); +}; +const useSubscribeFocusedFiber = (onUpdate: () => void) => { + // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + useEffect(() => { + const subscribe = () => { + if (Store.inspectState.value.kind !== 'focused') { + return; + } + onUpdate(); + }; + + const unSubReportTime = Store.lastReportTime.subscribe(subscribe); + const unSubState = Store.inspectState.subscribe(subscribe); + return () => { + unSubReportTime(); + unSubState(); + }; + }, []); +}; + +export const Header = () => { + const refRaf = useRef(null); + const refComponentName = useRef(null); + const refMetrics = useRef(null); + + useSubscribeFocusedFiber(() => { + + cancelAnimationFrame(refRaf.current ?? 0); + refRaf.current = requestAnimationFrame(() => { + if (Store.inspectState.value.kind !== 'focused') return; + const focusedElement = Store.inspectState.value.focusedDomElement; + const { parentCompositeFiber } = getCompositeComponentFromElement(focusedElement); + if (!parentCompositeFiber) return; + + const displayName = getDisplayName(parentCompositeFiber.type); + const reportData = Store.reportData.get(parentCompositeFiber); + const count = reportData?.count || 0; + const time = reportData?.time || 0; + + if (refComponentName.current && refMetrics.current) { + refComponentName.current.dataset.text = displayName ?? 'Unknown'; + const formattedTime = time > 0 + ? time < 0.1 - Number.EPSILON + ? '< 0.1ms' + : `${Number(time.toFixed(1))}ms` + : ''; + + refMetrics.current.dataset.text = `${count} re-renders${formattedTime ? ` • ${formattedTime}` : ''}`; + } + }); + }); + + const handleClose = () => { + Store.inspectState.value = { + kind: 'inspect-off', + }; + }; + + return ( +
+ + + + +
+ ); +}; diff --git a/packages/scan/src/web/components/widget/helpers.ts b/packages/scan/src/web/components/widget/helpers.ts new file mode 100644 index 0000000..ca91636 --- /dev/null +++ b/packages/scan/src/web/components/widget/helpers.ts @@ -0,0 +1,354 @@ +import { SAFE_AREA, MIN_SIZE } from '../../constants'; +import type { Corner, Position, ResizeHandleProps, Size } from './types'; + +export const getWindowDimensions = (() => { + let cache: { + width: number; + height: number; + maxWidth: number; + maxHeight: number; + rightEdge: (width: number) => number; + bottomEdge: (height: number) => number; + isFullWidth: (width: number) => boolean; + isFullHeight: (height: number) => boolean; + } | null = null; + + return () => { + const currentWidth = window.innerWidth; + const currentHeight = window.innerHeight; + + if ( + cache && + cache.width === currentWidth && + cache.height === currentHeight + ) { + return { + maxWidth: cache.maxWidth, + maxHeight: cache.maxHeight, + rightEdge: cache.rightEdge, + bottomEdge: cache.bottomEdge, + isFullWidth: cache.isFullWidth, + isFullHeight: cache.isFullHeight, + }; + } + + const maxWidth = currentWidth - SAFE_AREA * 2; + const maxHeight = currentHeight - SAFE_AREA * 2; + + cache = { + width: currentWidth, + height: currentHeight, + maxWidth, + maxHeight, + rightEdge: (width: number) => currentWidth - width - SAFE_AREA, + bottomEdge: (height: number) => currentHeight - height - SAFE_AREA, + isFullWidth: (width: number) => width >= maxWidth, + isFullHeight: (height: number) => height >= maxHeight, + }; + + return { + maxWidth: cache.maxWidth, + maxHeight: cache.maxHeight, + rightEdge: cache.rightEdge, + bottomEdge: cache.bottomEdge, + isFullWidth: cache.isFullWidth, + isFullHeight: cache.isFullHeight, + }; + }; +})(); + +export const getOppositeCorner = ( + position: ResizeHandleProps['position'], + currentCorner: Corner, + isFullScreen: boolean, + isFullWidth?: boolean, + isFullHeight?: boolean, +): Corner => { + // For full screen mode + if (isFullScreen) { + if (position === 'top-left') return 'bottom-right'; + if (position === 'top-right') return 'bottom-left'; + if (position === 'bottom-left') return 'top-right'; + if (position === 'bottom-right') return 'top-left'; + + const [vertical, horizontal] = currentCorner.split('-'); + if (position === 'left') return `${vertical}-right` as Corner; + if (position === 'right') return `${vertical}-left` as Corner; + if (position === 'top') return `bottom-${horizontal}` as Corner; + if (position === 'bottom') return `top-${horizontal}` as Corner; + } + + // For full width mode + if (isFullWidth) { + if (position === 'left') + return `${currentCorner.split('-')[0]}-right` as Corner; + if (position === 'right') + return `${currentCorner.split('-')[0]}-left` as Corner; + } + + // For full height mode + if (isFullHeight) { + if (position === 'top') + return `bottom-${currentCorner.split('-')[1]}` as Corner; + if (position === 'bottom') + return `top-${currentCorner.split('-')[1]}` as Corner; + } + + return currentCorner; +}; + +export const calculatePosition = ( + corner: Corner, + width: number, + height: number, +): Position => { + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + // Check if widget is minimized + const isMinimized = width === MIN_SIZE.width; + + // Only bound dimensions if minimized + const effectiveWidth = isMinimized + ? width + : Math.min(width, windowWidth - SAFE_AREA * 2); + const effectiveHeight = isMinimized + ? height + : Math.min(height, windowHeight - SAFE_AREA * 2); + + // Calculate base positions + let x: number; + let y: number; + + switch (corner) { + case 'top-right': + x = windowWidth - effectiveWidth - SAFE_AREA; + y = SAFE_AREA; + break; + case 'bottom-right': + x = windowWidth - effectiveWidth - SAFE_AREA; + y = windowHeight - effectiveHeight - SAFE_AREA; + break; + case 'bottom-left': + x = SAFE_AREA; + y = windowHeight - effectiveHeight - SAFE_AREA; + break; + case 'top-left': + x = SAFE_AREA; + y = SAFE_AREA; + break; + default: + x = SAFE_AREA; + y = SAFE_AREA; + break; + } + + // Only ensure positions are within bounds if minimized + if (isMinimized) { + x = Math.max( + SAFE_AREA, + Math.min(x, windowWidth - effectiveWidth - SAFE_AREA), + ); + y = Math.max( + SAFE_AREA, + Math.min(y, windowHeight - effectiveHeight - SAFE_AREA), + ); + } + + return { x, y }; +}; + +const positionMatchesCorner = ( + position: ResizeHandleProps['position'], + corner: Corner, +): boolean => { + const [vertical, horizontal] = corner.split('-'); + return position !== vertical && position !== horizontal; +}; + +export const getHandleVisibility = ( + position: ResizeHandleProps['position'], + corner: Corner, + isFullWidth: boolean, + isFullHeight: boolean, +): boolean => { + if (isFullWidth && isFullHeight) { + return true; + } + + // Normal state + if (!isFullWidth && !isFullHeight) { + return positionMatchesCorner(position, corner); + } + + // Full width state + if (isFullWidth) { + return position !== corner.split('-')[0]; + } + + // Full height state + if (isFullHeight) { + return position !== corner.split('-')[1]; + } + + return false; +}; + +export const calculateBoundedSize = ( + currentSize: number, + delta: number, + isWidth: boolean, +): number => { + const min = isWidth ? MIN_SIZE.width : MIN_SIZE.height * 5; + const max = isWidth + ? getWindowDimensions().maxWidth + : getWindowDimensions().maxHeight; + + const newSize = currentSize + delta; + return Math.min(Math.max(min, newSize), max); +}; + +export const calculateNewSizeAndPosition = ( + position: ResizeHandleProps['position'], + initialSize: Size, + initialPosition: Position, + deltaX: number, + deltaY: number, +): { newSize: Size; newPosition: Position } => { + const maxWidth = window.innerWidth - SAFE_AREA * 2; + const maxHeight = window.innerHeight - SAFE_AREA * 2; + + let newWidth = initialSize.width; + let newHeight = initialSize.height; + let newX = initialPosition.x; + let newY = initialPosition.y; + + // horizontal resize + if (position.includes('right')) { + // Check if we have enough space on the right + const availableWidth = window.innerWidth - initialPosition.x - SAFE_AREA; + const proposedWidth = Math.min(initialSize.width + deltaX, availableWidth); + newWidth = Math.min(maxWidth, Math.max(MIN_SIZE.width, proposedWidth)); + } + if (position.includes('left')) { + // Check if we have enough space on the left + const availableWidth = initialPosition.x + initialSize.width - SAFE_AREA; + const proposedWidth = Math.min(initialSize.width - deltaX, availableWidth); + newWidth = Math.min(maxWidth, Math.max(MIN_SIZE.width, proposedWidth)); + newX = initialPosition.x - (newWidth - initialSize.width); + } + + // vertical resize + if (position.includes('bottom')) { + // Check if we have enough space at the bottom + const availableHeight = window.innerHeight - initialPosition.y - SAFE_AREA; + const proposedHeight = Math.min( + initialSize.height + deltaY, + availableHeight, + ); + newHeight = Math.min( + maxHeight, + Math.max(MIN_SIZE.height * 5, proposedHeight), + ); + } + if (position.includes('top')) { + // Check if we have enough space at the top + const availableHeight = initialPosition.y + initialSize.height - SAFE_AREA; + const proposedHeight = Math.min( + initialSize.height - deltaY, + availableHeight, + ); + newHeight = Math.min( + maxHeight, + Math.max(MIN_SIZE.height * 5, proposedHeight), + ); + newY = initialPosition.y - (newHeight - initialSize.height); + } + + // Ensure position stays within bounds + newX = Math.max( + SAFE_AREA, + Math.min(newX, window.innerWidth - SAFE_AREA - newWidth), + ); + newY = Math.max( + SAFE_AREA, + Math.min(newY, window.innerHeight - SAFE_AREA - newHeight), + ); + + return { + newSize: { width: newWidth, height: newHeight }, + newPosition: { x: newX, y: newY }, + }; +}; + +export const getClosestCorner = (position: Position): Corner => { + const { maxWidth, maxHeight } = getWindowDimensions(); + + const distances = { + 'top-left': Math.hypot(position.x, position.y), + 'top-right': Math.hypot(maxWidth - position.x, position.y), + 'bottom-left': Math.hypot(position.x, maxHeight - position.y), + 'bottom-right': Math.hypot(maxWidth - position.x, maxHeight - position.y), + }; + + return Object.entries(distances).reduce( + (closest, [corner, distance]) => { + return distance < distances[closest] ? (corner as Corner) : closest; + }, + 'top-left', + ); +}; + +// Helper to determine best corner based on cursor position, widget size, and movement +export const getBestCorner = ( + mouseX: number, + mouseY: number, + initialMouseX?: number, + initialMouseY?: number, + threshold = 100, +): Corner => { + const deltaX = initialMouseX !== undefined ? mouseX - initialMouseX : 0; + const deltaY = initialMouseY !== undefined ? mouseY - initialMouseY : 0; + + const windowCenterX = window.innerWidth / 2; + const windowCenterY = window.innerHeight / 2; + + // Determine movement direction + const movingRight = deltaX > threshold; + const movingLeft = deltaX < -threshold; + const movingDown = deltaY > threshold; + const movingUp = deltaY < -threshold; + + // If horizontal movement + if (movingRight || movingLeft) { + const isBottom = mouseY > windowCenterY; + return movingRight + ? isBottom + ? 'bottom-right' + : 'top-right' + : isBottom + ? 'bottom-left' + : 'top-left'; + } + + // If vertical movement + if (movingDown || movingUp) { + const isRight = mouseX > windowCenterX; + return movingDown + ? isRight + ? 'bottom-right' + : 'bottom-left' + : isRight + ? 'top-right' + : 'top-left'; + } + + // If no significant movement, use quadrant-based position + return mouseX > windowCenterX + ? mouseY > windowCenterY + ? 'bottom-right' + : 'top-right' + : mouseY > windowCenterY + ? 'bottom-left' + : 'top-left'; +}; diff --git a/packages/scan/src/web/components/widget/index.tsx b/packages/scan/src/web/components/widget/index.tsx new file mode 100644 index 0000000..db7a8d2 --- /dev/null +++ b/packages/scan/src/web/components/widget/index.tsx @@ -0,0 +1,401 @@ +import type { JSX } from 'preact'; +import { useCallback, useEffect, useRef } from 'preact/hooks'; +import { saveLocalStorage, toggleMultipleClasses, debounce, cn } from '~web/utils/helpers'; +import { ScanOverlay } from '~web/components/inspector/overlay'; +import { Store } from '~core/index'; +import { Inspector } from '../inspector'; +import { LOCALSTORAGE_KEY, MIN_SIZE, SAFE_AREA } from '../../constants'; +import { + defaultWidgetConfig, + signalRefContainer, + signalWidget, + updateDimensions, +} from '../../state'; +import { Header } from './header'; +import { + calculateBoundedSize, + calculatePosition, + getBestCorner, +} from './helpers'; +import { ResizeHandle } from './resize-handle'; +import Toolbar from './toolbar'; + +export const Widget = () => { + const refShouldExpand = useRef(false); + + const refContainer = useRef(null); + const refContent = useRef(null); + const refFooter = useRef(null); + + const refInitialMinimizedWidth = useRef(0); + const refInitialMinimizedHeight = useRef(0); + + const updateWidgetPosition = useCallback((shouldSave = true) => { + if (!refContainer.current) return; + + const inspectState = Store.inspectState.value; + const isInspectFocused = inspectState.kind === 'focused'; + + const { corner } = signalWidget.value; + let newWidth: number; + let newHeight: number; + + if (isInspectFocused) { + const lastDims = signalWidget.value.lastDimensions; + newWidth = calculateBoundedSize(lastDims.width, 0, true); + newHeight = calculateBoundedSize(lastDims.height, 0, false); + } else { + const currentDims = signalWidget.value.dimensions; + if (currentDims.width > refInitialMinimizedWidth.current) { + signalWidget.value = { + ...signalWidget.value, + lastDimensions: { + isFullWidth: currentDims.isFullWidth, + isFullHeight: currentDims.isFullHeight, + width: currentDims.width, + height: currentDims.height, + position: currentDims.position, + }, + }; + } + newWidth = refInitialMinimizedWidth.current; + newHeight = refInitialMinimizedHeight.current; + } + + const newPosition = calculatePosition(corner, newWidth, newHeight); + + const isTooSmall = newWidth < MIN_SIZE.width || newHeight < MIN_SIZE.height * 5; + const shouldPersist = shouldSave && !isTooSmall; + + const container = refContainer.current; + const containerStyle = container.style; + + let rafId: number | null = null; + const onTransitionEnd = () => { + containerStyle.transition = 'none'; + updateDimensions(); + container.removeEventListener('transitionend', onTransitionEnd); + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; + + container.addEventListener('transitionend', onTransitionEnd); + containerStyle.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + + rafId = requestAnimationFrame(() => { + containerStyle.width = `${newWidth}px`; + containerStyle.height = `${newHeight}px`; + containerStyle.transform = `translate3d(${newPosition.x}px, ${newPosition.y}px, 0)`; + rafId = null; + }); + + const newDimensions = { + isFullWidth: newWidth >= window.innerWidth - SAFE_AREA * 2, + isFullHeight: newHeight >= window.innerHeight - SAFE_AREA * 2, + width: newWidth, + height: newHeight, + position: newPosition, + }; + + signalWidget.value = { + corner, + dimensions: newDimensions, + lastDimensions: isInspectFocused + ? signalWidget.value.lastDimensions + : newWidth > refInitialMinimizedWidth.current + ? newDimensions + : signalWidget.value.lastDimensions, + }; + + if (shouldPersist) { + saveLocalStorage(LOCALSTORAGE_KEY, { + corner: signalWidget.value.corner, + dimensions: signalWidget.value.dimensions, + lastDimensions: signalWidget.value.lastDimensions, + }); + } + + updateDimensions(); + }, []); + + const handleDrag = useCallback((e: JSX.TargetedMouseEvent) => { + e.preventDefault(); + + if (!refContainer.current || (e.target as HTMLElement).closest('button')) + return; + + const container = refContainer.current; + const containerStyle = container.style; + const { dimensions } = signalWidget.value; + + const initialMouseX = e.clientX; + const initialMouseY = e.clientY; + + const initialX = dimensions.position.x; + const initialY = dimensions.position.y; + + let currentX = initialX; + let currentY = initialY; + let rafId: number | null = null; + let hasMoved = false; + let lastMouseX = initialMouseX; + let lastMouseY = initialMouseY; + + const handleMouseMove = (e: globalThis.MouseEvent) => { + if (rafId) return; + + hasMoved = true; + lastMouseX = e.clientX; + lastMouseY = e.clientY; + + rafId = requestAnimationFrame(() => { + const deltaX = lastMouseX - initialMouseX; + const deltaY = lastMouseY - initialMouseY; + + currentX = Number(initialX) + deltaX; + currentY = Number(initialY) + deltaY; + + containerStyle.transition = 'none'; + containerStyle.transform = `translate3d(${currentX}px, ${currentY}px, 0)`; + rafId = null; + }); + }; + + const handleMouseUp = () => { + if (!container) return; + + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + + if (!hasMoved) return; + + const newCorner = getBestCorner( + lastMouseX, + lastMouseY, + initialMouseX, + initialMouseY, + Store.inspectState.value.kind === 'focused' ? 80 : 40, + ); + + if (newCorner === signalWidget.value.corner) { + containerStyle.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + const currentPosition = signalWidget.value.dimensions.position; + requestAnimationFrame(() => { + containerStyle.transform = `translate3d(${currentPosition.x}px, ${currentPosition.y}px, 0)`; + }); + return; + } + + const snappedPosition = calculatePosition( + newCorner, + dimensions.width, + dimensions.height, + ); + + if (currentX === initialX && currentY === initialY) return; + + const onTransitionEnd = () => { + containerStyle.transition = 'none'; + updateDimensions(); + container.removeEventListener('transitionend', onTransitionEnd); + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; + + container.addEventListener('transitionend', onTransitionEnd); + containerStyle.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + + requestAnimationFrame(() => { + containerStyle.transform = `translate3d(${snappedPosition.x}px, ${snappedPosition.y}px, 0)`; + }); + + signalWidget.value = { + corner: newCorner, + dimensions: { + isFullWidth: dimensions.isFullWidth, + isFullHeight: dimensions.isFullHeight, + width: dimensions.width, + height: dimensions.height, + position: snappedPosition, + }, + lastDimensions: signalWidget.value.lastDimensions, + }; + + saveLocalStorage(LOCALSTORAGE_KEY, { + corner: newCorner, + dimensions: signalWidget.value.dimensions, + lastDimensions: signalWidget.value.lastDimensions, + }); + }; + + document.addEventListener('mousemove', handleMouseMove, { passive: true }); + document.addEventListener('mouseup', handleMouseUp); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + useEffect(() => { + if (!refContainer.current || !refFooter.current) return; + + refContainer.current.style.width = 'min-content'; + refInitialMinimizedHeight.current = refFooter.current.offsetHeight; + refInitialMinimizedWidth.current = refContainer.current.offsetWidth; + + refContainer.current.style.maxWidth = `calc(100vw - ${SAFE_AREA * 2}px)`; + refContainer.current.style.maxHeight = `calc(100vh - ${SAFE_AREA * 2}px)`; + + if (Store.inspectState.value.kind !== 'focused') { + signalWidget.value = { + ...signalWidget.value, + dimensions: { + isFullWidth: false, + isFullHeight: false, + width: refInitialMinimizedWidth.current, + height: refInitialMinimizedHeight.current, + position: signalWidget.value.dimensions.position, + }, + }; + } + + signalRefContainer.value = refContainer.current; + + const unsubscribeSignalWidget = signalWidget.subscribe((widget) => { + if (!refContainer.current) return; + + const { x, y } = widget.dimensions.position; + const { width, height } = widget.dimensions; + const container = refContainer.current; + + requestAnimationFrame(() => { + container.style.transform = `translate3d(${x}px, ${y}px, 0)`; + container.style.width = `${width}px`; + container.style.height = `${height}px`; + }); + }); + + const unsubscribeStoreInspectState = Store.inspectState.subscribe( + (state) => { + if (!refContent.current) return; + + refShouldExpand.current = state.kind === 'focused'; + + if (state.kind === 'inspecting') { + toggleMultipleClasses(refContent.current, [ + 'opacity-0', + 'duration-0', + 'delay-0', + ]); + } + updateWidgetPosition(); + }, + ); + + const handleWindowResize = debounce(() => { + updateWidgetPosition(true); + }, 100); + + window.addEventListener('resize', handleWindowResize, { passive: true }); + updateWidgetPosition(false); + + return () => { + window.removeEventListener('resize', handleWindowResize); + unsubscribeStoreInspectState(); + unsubscribeSignalWidget(); + + saveLocalStorage(LOCALSTORAGE_KEY, { + ...defaultWidgetConfig, + corner: signalWidget.value.corner, + }); + }; + }, []); + + return ( + <> + +
+ + + + + +
+
+
+
+ +
+
+ +
+ +
+
+
+ + ); +}; diff --git a/packages/scan/src/web/components/widget/resize-handle.tsx b/packages/scan/src/web/components/widget/resize-handle.tsx new file mode 100644 index 0000000..4d4f50a --- /dev/null +++ b/packages/scan/src/web/components/widget/resize-handle.tsx @@ -0,0 +1,329 @@ +import type { JSX } from 'preact'; +import { useCallback, useEffect, useRef } from 'preact/hooks'; +import { saveLocalStorage, cn } from '~web/utils/helpers'; +import { Icon } from '~web/components/icon'; +import { Store } from '~core/index'; +import { LOCALSTORAGE_KEY, MIN_SIZE } from '../../constants'; +import { signalRefContainer, signalWidget } from '../../state'; +import { + calculateNewSizeAndPosition, + calculatePosition, + getClosestCorner, + getHandleVisibility, + getOppositeCorner, + getWindowDimensions, +} from './helpers'; +import type { Corner, ResizeHandleProps } from './types'; + +export const ResizeHandle = ({ position }: ResizeHandleProps) => { + const refContainer = useRef(null); + + const prevWidth = useRef(null); + const prevHeight = useRef(null); + const prevCorner = useRef(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + useEffect(() => { + if (!refContainer.current) return; + + const updateVisibility = (isFocused: boolean) => { + if (!refContainer.current) return; + const isVisible = + isFocused && + getHandleVisibility( + position, + signalWidget.value.corner, + signalWidget.value.dimensions.isFullWidth, + signalWidget.value.dimensions.isFullHeight, + ); + + if (isVisible) { + refContainer.current.classList.remove( + 'hidden', + 'pointer-events-none', + 'opacity-0', + ); + } else { + refContainer.current.classList.add( + 'hidden', + 'pointer-events-none', + 'opacity-0', + ); + } + }; + + const unsubscribeSignalWidget = signalWidget.subscribe((state) => { + if (!refContainer.current) return; + + if ( + prevWidth.current !== null && + prevHeight.current !== null && + prevCorner.current !== null && + state.dimensions.width === prevWidth.current && + state.dimensions.height === prevHeight.current && + state.corner === prevCorner.current + ) { + return; + } + + updateVisibility(Store.inspectState.value.kind === 'focused'); + + prevWidth.current = state.dimensions.width; + prevHeight.current = state.dimensions.height; + prevCorner.current = state.corner; + }); + + const unsubscribeStoreInspectState = Store.inspectState.subscribe( + (state) => { + if (!refContainer.current) return; + updateVisibility(state.kind === 'focused'); + }, + ); + + return () => { + unsubscribeSignalWidget(); + unsubscribeStoreInspectState(); + prevWidth.current = null; + prevHeight.current = null; + prevCorner.current = null; + }; + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + const handleResize = useCallback((e: JSX.TargetedMouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const container = signalRefContainer.value; + if (!container) return; + + const containerStyle = container.style; + const { dimensions } = signalWidget.value; + const initialX = e.clientX; + const initialY = e.clientY; + + const initialWidth = dimensions.width; + const initialHeight = dimensions.height; + const initialPosition = dimensions.position; + + signalWidget.value = { + ...signalWidget.value, + dimensions: { + ...dimensions, + isFullWidth: false, + isFullHeight: false, + width: initialWidth, + height: initialHeight, + position: initialPosition, + }, + }; + + let rafId: number | null = null; + + const handleMouseMove = (e: MouseEvent) => { + if (rafId) return; + + containerStyle.transition = 'none'; + + rafId = requestAnimationFrame(() => { + const { newSize, newPosition } = calculateNewSizeAndPosition( + position, + { width: initialWidth, height: initialHeight }, + initialPosition, + e.clientX - initialX, + e.clientY - initialY, + ); + + containerStyle.transform = `translate3d(${newPosition.x}px, ${newPosition.y}px, 0)`; + containerStyle.width = `${newSize.width}px`; + containerStyle.height = `${newSize.height}px`; + + signalWidget.value = { + ...signalWidget.value, + dimensions: { + isFullWidth: false, + isFullHeight: false, + width: newSize.width, + height: newSize.height, + position: newPosition, + }, + }; + + rafId = null; + }); + }; + + const handleMouseUp = () => { + if (rafId) { + cancelAnimationFrame(rafId); + rafId = null; + } + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + + const { dimensions, corner } = signalWidget.value; + const { isFullWidth, isFullHeight } = getWindowDimensions(); + const isCurrentFullWidth = isFullWidth(dimensions.width); + const isCurrentFullHeight = isFullHeight(dimensions.height); + const isFullScreen = isCurrentFullWidth && isCurrentFullHeight; + + let newCorner = corner; + if (isFullScreen || isCurrentFullWidth || isCurrentFullHeight) { + newCorner = getClosestCorner(dimensions.position); + } + + const newPosition = calculatePosition( + newCorner, + dimensions.width, + dimensions.height, + ); + + const onTransitionEnd = () => { + container.removeEventListener('transitionend', onTransitionEnd); + }; + + container.addEventListener('transitionend', onTransitionEnd); + containerStyle.transform = `translate3d(${newPosition.x}px, ${newPosition.y}px, 0)`; + + signalWidget.value = { + corner: newCorner, + dimensions: { + isFullWidth: isCurrentFullWidth, + isFullHeight: isCurrentFullHeight, + width: dimensions.width, + height: dimensions.height, + position: newPosition, + }, + lastDimensions: { + isFullWidth: isCurrentFullWidth, + isFullHeight: isCurrentFullHeight, + width: dimensions.width, + height: dimensions.height, + position: newPosition, + }, + }; + + saveLocalStorage(LOCALSTORAGE_KEY, { + corner: newCorner, + dimensions: signalWidget.value.dimensions, + lastDimensions: signalWidget.value.lastDimensions, + }); + }; + + document.addEventListener('mousemove', handleMouseMove, { + passive: true, + }); + document.addEventListener('mouseup', handleMouseUp); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + const handleDoubleClick = useCallback((e: JSX.TargetedMouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const container = signalRefContainer.value; + if (!container) return; + + const containerStyle = container.style; + const { dimensions, corner } = signalWidget.value; + const { maxWidth, maxHeight, isFullWidth, isFullHeight } = + getWindowDimensions(); + + const isCurrentFullWidth = isFullWidth(dimensions.width); + const isCurrentFullHeight = isFullHeight(dimensions.height); + const isFullScreen = isCurrentFullWidth && isCurrentFullHeight; + const isPartiallyMaximized = + (isCurrentFullWidth || isCurrentFullHeight) && !isFullScreen; + + let newWidth = dimensions.width; + let newHeight = dimensions.height; + const newCorner = getOppositeCorner( + position, + corner, + isFullScreen, + isCurrentFullWidth, + isCurrentFullHeight, + ); + + if (position === 'left' || position === 'right') { + newWidth = isCurrentFullWidth ? dimensions.width : maxWidth; + if (isPartiallyMaximized) { + newWidth = isCurrentFullWidth ? MIN_SIZE.width : maxWidth; + } + } else { + newHeight = isCurrentFullHeight ? dimensions.height : maxHeight; + if (isPartiallyMaximized) { + newHeight = isCurrentFullHeight ? MIN_SIZE.height * 5 : maxHeight; + } + } + + if (isFullScreen) { + if (position === 'left' || position === 'right') { + newWidth = MIN_SIZE.width; + } else { + newHeight = MIN_SIZE.height * 5; + } + } + + const newPosition = calculatePosition(newCorner, newWidth, newHeight); + const newDimensions = { + isFullWidth: isFullWidth(newWidth), + isFullHeight: isFullHeight(newHeight), + width: newWidth, + height: newHeight, + position: newPosition, + }; + + requestAnimationFrame(() => { + signalWidget.value = { + corner: newCorner, + dimensions: newDimensions, + lastDimensions: dimensions, + }; + + containerStyle.transition = 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; + containerStyle.width = `${newWidth}px`; + containerStyle.height = `${newHeight}px`; + containerStyle.transform = `translate3d(${newPosition.x}px, ${newPosition.y}px, 0)`; + }); + + saveLocalStorage(LOCALSTORAGE_KEY, { + corner: newCorner, + dimensions: newDimensions, + lastDimensions: dimensions, + }); + }, []); + + return ( +
+ + + + + +
+ ); +}; diff --git a/packages/scan/src/web/components/widget/toolbar/arrows.tsx b/packages/scan/src/web/components/widget/toolbar/arrows.tsx new file mode 100644 index 0000000..f4e1795 --- /dev/null +++ b/packages/scan/src/web/components/widget/toolbar/arrows.tsx @@ -0,0 +1,128 @@ +import { memo } from "preact/compat"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { Store } from "~core/index"; +import { Icon } from "~web/components/icon"; +import { type InspectableElement, getInspectableElements } from "~web/components/inspector/utils"; +import { cn } from "~web/utils/helpers"; + +export const Arrows = memo(() => { + const refButtonPrevious = useRef(null); + const refButtonNext = useRef(null); + const refAllElements = useRef>([]); + + const [shouldRender, setShouldRender] = useState(false); + + const findNextElement = useCallback( + (currentElement: HTMLElement, direction: 'next' | 'previous') => { + const currentIndex = refAllElements.current.findIndex(item => item.element === currentElement); + if (currentIndex === -1) return null; + + const nextIndex = currentIndex + (direction === 'next' ? 1 : -1); + return refAllElements.current[nextIndex]?.element || null; + }, + [] + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + const onPreviousFocus = useCallback(() => { + const currentState = Store.inspectState.value; + if (currentState.kind !== 'focused' || !currentState.focusedDomElement) + return; + + const prevElement = findNextElement( + currentState.focusedDomElement, + 'previous', + ); + if (prevElement) { + Store.inspectState.value = { + kind: 'focused', + focusedDomElement: prevElement, + }; + } + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + const onNextFocus = useCallback(() => { + const currentState = Store.inspectState.value; + if (currentState.kind !== 'focused' || !currentState.focusedDomElement) + return; + + const nextElement = findNextElement(currentState.focusedDomElement, 'next'); + if (nextElement) { + Store.inspectState.value = { + kind: 'focused', + focusedDomElement: nextElement + }; + } + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + useEffect(() => { + const unsubscribe = Store.inspectState.subscribe(state => { + if (state.kind === 'focused') { + refAllElements.current = getInspectableElements(); + setShouldRender(true); + if (refButtonPrevious.current) { + const hasPrevious = !!findNextElement(state.focusedDomElement, 'previous'); + refButtonPrevious.current.classList.toggle('opacity-50', !hasPrevious); + refButtonPrevious.current.classList.toggle('cursor-not-allowed', !hasPrevious); + } + if (refButtonNext.current) { + const hasNext = !!findNextElement(state.focusedDomElement, 'next'); + refButtonNext.current.classList.toggle('opacity-50', !hasNext); + refButtonNext.current.classList.toggle('cursor-not-allowed', !hasNext); + } + } + + if (state.kind === 'inspecting') { + setShouldRender(true); + } + + if (state.kind === 'inspect-off') { + refAllElements.current = []; + } + + if (state.kind === 'uninitialized') { + Store.inspectState.value = { + kind: 'inspect-off', + }; + } + }); + + return () => { + unsubscribe(); + }; + }, []); + + if (!shouldRender) return null; + + return ( +
+ + +
+ ); +}); diff --git a/packages/scan/src/web/components/widget/toolbar/index.tsx b/packages/scan/src/web/components/widget/toolbar/index.tsx new file mode 100644 index 0000000..7c85bd6 --- /dev/null +++ b/packages/scan/src/web/components/widget/toolbar/index.tsx @@ -0,0 +1,147 @@ +import { memo } from 'preact/compat'; +import { useCallback, useEffect } from 'preact/hooks'; +import { Store, ReactScanInternals, setOptions } from '~core/index'; +import { Icon } from '~web/components/icon'; +import FpsMeter from '~web/components/widget/fps-meter'; +import { Arrows } from '~web/components/widget/toolbar/arrows'; +import { cn } from '~web/utils/helpers'; + +export const Toolbar = memo(() => { + const inspectState = Store.inspectState; + + const isInspectActive = inspectState.value.kind === 'inspecting'; + const isInspectFocused = inspectState.value.kind === 'focused'; + + const onToggleInspect = useCallback(() => { + const currentState = Store.inspectState.value; + + switch (currentState.kind) { + case 'inspecting': + Store.inspectState.value = { + kind: 'inspect-off', + }; + break; + case 'focused': + Store.inspectState.value = { + kind: 'inspect-off', + }; + break; + case 'inspect-off': + Store.inspectState.value = { + kind: 'inspecting', + hoveredDomElement: null, + }; + break; + case 'uninitialized': + break; + } + }, []); + + const onToggleActive = useCallback(() => { + if (ReactScanInternals.instrumentation) { + ReactScanInternals.instrumentation.isPaused.value = + !ReactScanInternals.instrumentation.isPaused.value; + } + }, []); + + const onSoundToggle = useCallback(() => { + const newSoundState = !ReactScanInternals.options.value.playSound; + setOptions({ playSound: newSoundState }); + }, []); + + useEffect(() => { + const unsubscribe = Store.inspectState.subscribe(state => { + if (state.kind === 'uninitialized') { + Store.inspectState.value = { + kind: 'inspect-off', + }; + } + }); + + return () => { + unsubscribe(); + }; + }, []); + + let inspectIcon = null; + let inspectColor = '#999'; + + if (isInspectActive) { + inspectIcon = ; + inspectColor = 'rgba(142, 97, 227, 1)'; + } else if (isInspectFocused) { + inspectIcon = ; + inspectColor = 'rgba(142, 97, 227, 1)'; + } else { + inspectIcon = ; + inspectColor = '#999'; + } + + return ( +
+ + + + + + +
+ react-scan + +
+
+ ); +}); + +export default Toolbar; diff --git a/packages/scan/src/web/components/widget/toolbar/search.tsx b/packages/scan/src/web/components/widget/toolbar/search.tsx new file mode 100644 index 0000000..e81888d --- /dev/null +++ b/packages/scan/src/web/components/widget/toolbar/search.tsx @@ -0,0 +1,150 @@ +// TODO: @pivanov - improve UI and finish the implementation +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { Store } from "~core/index"; +import { getInspectableElements } from "~web/components/inspector/utils"; +import { cn } from "~web/utils/helpers"; + + +export const Search = () => { + const [search, setSearch] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Add search state + const [isOpen, setIsOpen] = useState(false); + + const handleClose = useCallback(() => { + setIsOpen(false); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: no deps + const handleSelect = useCallback((element: HTMLElement) => { + Store.inspectState.value = { + kind: 'focused', + focusedDomElement: element, + }; + + handleClose(); + }, []); + + // Add keyboard shortcut for search + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + setIsOpen(true); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + const elements = useMemo(() => getInspectableElements(), []); + + // Get current focused element + const currentElement = Store.inspectState.value.kind === 'focused' + ? Store.inspectState.value.focusedDomElement + : null; + + const filteredElements = useMemo(() => { + if (!search) return elements; + const searchLower = search.toLowerCase(); + return elements.filter(item => + item.name.toLowerCase().includes(searchLower) + ); + }, [elements, search]); + + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + + // Find and select current element in the list + if (currentElement) { + const index = filteredElements.findIndex(item => item.element === currentElement); + if (index !== -1) { + setSelectedIndex(index); + // Scroll the item into view + requestAnimationFrame(() => { + const itemElement = listRef.current?.children[index] as HTMLElement; + if (itemElement) { + itemElement.scrollIntoView({ block: 'center' }); + } + }); + } + } + } + }, [isOpen, currentElement, filteredElements]); + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex(i => + i < filteredElements.length - 1 ? i + 1 : i + ); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex(i => i > 0 ? i - 1 : i); + break; + case 'Enter': + e.preventDefault(); + if (filteredElements[selectedIndex]) { + handleSelect(filteredElements[selectedIndex].element); + } + break; + case 'Escape': + e.preventDefault(); + e.stopPropagation(); + handleClose(); + break; + } + }; + + if (!isOpen) return null; + + return ( +
+ { + setSearch(e.currentTarget.value); + setSelectedIndex(0); + }} + onKeyDown={handleKeyDown} + className="h-9 w-full border-b border-white/10 bg-transparent px-2 py-1 text-white focus:outline-none" + placeholder="Search components..." + /> +
+ {filteredElements.map((item, index) => ( + + ))} +
+
+ ); +}; diff --git a/packages/scan/src/web/components/widget/types.ts b/packages/scan/src/web/components/widget/types.ts new file mode 100644 index 0000000..d6ed4a2 --- /dev/null +++ b/packages/scan/src/web/components/widget/types.ts @@ -0,0 +1,35 @@ +export interface Position { + x: number; + y: number; +} + +export interface Size { + width: number; + height: number; +} + +export type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +export interface ResizeHandleProps { + position: Corner | 'top' | 'bottom' | 'left' | 'right'; +} + +export interface WidgetDimensions { + isFullWidth: boolean; + isFullHeight: boolean; + width: number; + height: number; + position: Position; +} + +export interface WidgetConfig { + corner: Corner; + dimensions: WidgetDimensions; + lastDimensions: WidgetDimensions; +} + +export interface WidgetSettings { + corner: Corner; + dimensions: WidgetDimensions; + lastDimensions: WidgetDimensions; +} diff --git a/packages/scan/src/web/constants.ts b/packages/scan/src/web/constants.ts new file mode 100644 index 0000000..fa4e42b --- /dev/null +++ b/packages/scan/src/web/constants.ts @@ -0,0 +1,7 @@ +export const SAFE_AREA = 24; +export const MIN_SIZE = { + width: 360, + height: 36, +} as const; + +export const LOCALSTORAGE_KEY = 'react-scan-widget-settings'; diff --git a/packages/scan/src/web/state.ts b/packages/scan/src/web/state.ts new file mode 100644 index 0000000..1b62d37 --- /dev/null +++ b/packages/scan/src/web/state.ts @@ -0,0 +1,73 @@ +import { signal } from '@preact/signals'; +import { readLocalStorage, saveLocalStorage } from './utils/helpers'; +import { MIN_SIZE, SAFE_AREA, LOCALSTORAGE_KEY } from './constants'; +import type { + Corner, + WidgetConfig, + WidgetSettings, +} from './components/widget/types'; + +export const signalRefContainer = signal(null); + +export const defaultWidgetConfig = { + corner: 'top-left' as Corner, + dimensions: { + isFullWidth: false, + isFullHeight: false, + width: MIN_SIZE.width, + height: MIN_SIZE.height, + position: { x: SAFE_AREA, y: SAFE_AREA }, + }, + lastDimensions: { + isFullWidth: false, + isFullHeight: false, + width: MIN_SIZE.width, + height: MIN_SIZE.height, + position: { x: SAFE_AREA, y: SAFE_AREA }, + }, +} as WidgetConfig; + +export const getInitialWidgetConfig = (): WidgetConfig => { + const stored = readLocalStorage(LOCALSTORAGE_KEY); + if (!stored) { + saveLocalStorage(LOCALSTORAGE_KEY, { + corner: defaultWidgetConfig.corner, + dimensions: defaultWidgetConfig.dimensions, + lastDimensions: defaultWidgetConfig.lastDimensions, + }); + + return defaultWidgetConfig; + } + + return { + corner: stored.corner, + dimensions: { + isFullWidth: false, + isFullHeight: false, + width: MIN_SIZE.width, + height: MIN_SIZE.height, + position: stored.dimensions.position, + }, + lastDimensions: stored.dimensions, + }; +}; + +export const signalWidget = signal(getInitialWidgetConfig()); + +export const updateDimensions = (): void => { + if (typeof window === 'undefined') return; + + const { dimensions } = signalWidget.value; + const { width, height, position } = dimensions; + + signalWidget.value = { + ...signalWidget.value, + dimensions: { + isFullWidth: width >= window.innerWidth - SAFE_AREA * 2, + isFullHeight: height >= window.innerHeight - SAFE_AREA * 2, + width, + height, + position, + }, + }; +}; diff --git a/packages/scan/src/web/toolbar.tsx b/packages/scan/src/web/toolbar.tsx new file mode 100644 index 0000000..5b2ac2b --- /dev/null +++ b/packages/scan/src/web/toolbar.tsx @@ -0,0 +1,25 @@ +import { render } from 'preact'; +import { Widget } from './components/widget'; + +export const createToolbar = (root: ShadowRoot): HTMLElement => { + const container = document.createElement('div'); + root.appendChild(container); + + render(, container); + + const originalRemove = container.remove.bind(container); + + container.remove = () => { + if (container.hasChildNodes()) { + // Double render(null) is needed to fully unmount Preact components. + // The first call initiates unmounting, while the second ensures + // cleanup of internal VNode references and event listeners. + render(null, container); + render(null, container); + } + + originalRemove(); + }; + + return container; +}; diff --git a/packages/scan/src/web/utils/geiger.ts b/packages/scan/src/web/utils/geiger.ts new file mode 100644 index 0000000..ca1ab89 --- /dev/null +++ b/packages/scan/src/web/utils/geiger.ts @@ -0,0 +1,108 @@ +// MIT License +// Copyright (c) 2024 Kristian Dupont + +import { isFirefox, readLocalStorage } from './helpers'; + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Taken from: https://github.com/kristiandupont/react-geiger/blob/main/src/Geiger.tsx + +// Simple throttle for high-frequency calls +let lastPlayTime = 0; +const MIN_INTERVAL = 32; // ~30fps throttle + +// Pre-calculate common values +const BASE_VOLUME = 0.5; +const FREQ_MULTIPLIER = 200; +const DEFAULT_VOLUME = 0.5; + +// Ensure volume is between 0 and 1 +const storedVolume = Math.max( + 0, + Math.min(1, readLocalStorage('react-scan-volume') ?? DEFAULT_VOLUME), +); + +// Audio configurations for different browsers +const config = { + firefox: { + duration: 0.02, + oscillatorType: 'sine' as const, + startFreq: 220, + endFreq: 110, + attack: 0.0005, + volumeMultiplier: storedVolume, + }, + default: { + duration: 0.001, + oscillatorType: 'sine' as const, + startFreq: 440, + endFreq: 220, + attack: 0.0005, + volumeMultiplier: storedVolume, + }, +} as const; // Make entire config readonly + +// Cache the selected config +const audioConfig = isFirefox ? config.firefox : config.default; + +/** + * Plays a Geiger counter-like click sound + * Cross-browser compatible version (Firefox, Chrome, Safari) + */ +export const playGeigerClickSound = ( + audioContext: AudioContext, + amplitude: number, +) => { + const now = performance.now(); + if (now - lastPlayTime < MIN_INTERVAL) { + return; + } + lastPlayTime = now; + + // Cache currentTime for consistent timing + const currentTime = audioContext.currentTime; + const { duration, oscillatorType, startFreq, endFreq, attack } = audioConfig; + + // Pre-calculate volume once + const volume = + Math.max(BASE_VOLUME, amplitude) * audioConfig.volumeMultiplier; + + // Create and configure nodes in one go + const oscillator = new OscillatorNode(audioContext, { + type: oscillatorType, + frequency: startFreq + amplitude * FREQ_MULTIPLIER, + }); + + const gainNode = new GainNode(audioContext, { + gain: 0, + }); + + // Schedule all parameters + oscillator.frequency.exponentialRampToValueAtTime( + endFreq, + currentTime + duration, + ); + gainNode.gain.linearRampToValueAtTime(volume, currentTime + attack); + + // Connect and schedule playback + oscillator.connect(gainNode).connect(audioContext.destination); + + oscillator.start(currentTime); + oscillator.stop(currentTime + duration); +}; diff --git a/packages/scan/src/web/utils/helpers.ts b/packages/scan/src/web/utils/helpers.ts new file mode 100644 index 0000000..e3474ff --- /dev/null +++ b/packages/scan/src/web/utils/helpers.ts @@ -0,0 +1,124 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export const cn = (...inputs: Array): string => { + return twMerge(clsx(inputs)); +}; + +export const isFirefox = + typeof navigator !== 'undefined' && navigator.userAgent.includes('Firefox'); + +export const onIdle = (callback: () => void) => { + if ('scheduler' in globalThis) { + return globalThis.scheduler.postTask(callback, { + priority: 'background', + }); + } + if ('requestIdleCallback' in window) { + return requestIdleCallback(callback); + } + return setTimeout(callback, 0); +}; + +export const throttle = unknown>( + callback: T, + delay: number, +) => { + let lastCall = 0; + return (...args: Parameters) => { + const now = Date.now(); + if (now - lastCall >= delay) { + lastCall = now; + return callback(...args); + } + }; +}; + +export const debounce = unknown>( + fn: T, + wait: number, + options: { leading?: boolean; trailing?: boolean } = {}, +) => { + let timeoutId: number | undefined; + let lastArgs: Parameters | undefined; + let isLeadingInvoked = false; + + const debounced = (...args: Parameters) => { + lastArgs = args; + + if (options.leading && !isLeadingInvoked) { + isLeadingInvoked = true; + fn(...args); + return; + } + + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + + if (options.trailing !== false) { + timeoutId = window.setTimeout(() => { + isLeadingInvoked = false; + timeoutId = undefined; + if (lastArgs) { + fn(...lastArgs); + } + }, wait); + } + }; + + debounced.cancel = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + isLeadingInvoked = false; + lastArgs = undefined; + } + }; + + return debounced; +}; + +export const createElement = (htmlString: string): HTMLElement => { + const template = document.createElement('template'); + template.innerHTML = htmlString.trim(); + return template.content.firstElementChild as HTMLElement; +}; + +export const tryOrElse = (fn: () => T, defaultValue: T): T => { + try { + return fn(); + } catch { + return defaultValue; + } +}; + +export const readLocalStorage = (storageKey: string): T | null => { + if (typeof window === 'undefined') return null; + + try { + const stored = localStorage.getItem(storageKey); + return stored ? JSON.parse(stored) : null; + } catch { + return null; + } +}; + +export const saveLocalStorage = (storageKey: string, state: T): void => { + if (typeof window === 'undefined') return; + + try { + window.localStorage.setItem(storageKey, JSON.stringify(state)); + } catch { + // Silently fail + } +}; + +export const toggleMultipleClasses = ( + element: HTMLElement, + classes: Array, +) => { + for (const cls of classes) { + element.classList.toggle(cls); + } +}; diff --git a/packages/scan/src/web/utils/log.ts b/packages/scan/src/web/utils/log.ts new file mode 100644 index 0000000..e084faa --- /dev/null +++ b/packages/scan/src/web/utils/log.ts @@ -0,0 +1,94 @@ +import type { Render } from '~core/instrumentation'; +import { getLabelText } from '~core/utils'; + +export const log = (renders: Array) => { + const logMap = new Map< + string, + Array<{ prev: unknown; next: unknown; type: string; unstable: boolean }> + >(); + for (let i = 0, len = renders.length; i < len; i++) { + const render = renders[i]; + + if (!render.componentName) continue; + + const changeLog = logMap.get(render.componentName) ?? []; + renders; + const labelText = getLabelText([ + { + aggregatedCount: 1, + + computedKey: null, + name: render.componentName, + frame: null, + ...render, + changes: { + type: new Set(render.changes.map((change) => change.type)), + unstable: render.changes.some((change) => change.unstable), + }, + phase: new Set([render.phase]), + computedCurrent: null, + }, + ]); + if (!labelText) continue; + + let prevChangedProps: Record | null = null; + let nextChangedProps: Record | null = null; + + if (render.changes) { + for (let i = 0, len = render.changes.length; i < len; i++) { + const { name, prevValue, nextValue, unstable, type } = + render.changes[i]; + if (type === 'props') { + prevChangedProps ??= {}; + nextChangedProps ??= {}; + prevChangedProps[`${unstable ? '⚠️' : ''}${name} (prev)`] = prevValue; + nextChangedProps[`${unstable ? '⚠️' : ''}${name} (next)`] = nextValue; + } else { + changeLog.push({ + prev: prevValue, + next: nextValue, + type, + unstable: unstable ?? false, + }); + } + } + } + + if (prevChangedProps && nextChangedProps) { + changeLog.push({ + prev: prevChangedProps, + next: nextChangedProps, + type: 'props', + unstable: false, + }); + } + + logMap.set(labelText, changeLog); + } + for (const [name, changeLog] of Array.from(logMap.entries())) { + // eslint-disable-next-line no-console + console.group( + `%c${name}`, + 'background: hsla(0,0%,70%,.3); border-radius:3px; padding: 0 2px;', + ); + for (const { type, prev, next, unstable } of changeLog) { + // eslint-disable-next-line no-console + console.log(`${type}:`, unstable ? '⚠️' : '', prev, '!==', next); + } + // eslint-disable-next-line no-console + console.groupEnd(); + } +}; + +export const logIntro = () => { + // eslint-disable-next-line no-console + console.log( + '%c[·] %cReact Scan', + 'font-weight:bold;color:#7a68e8;font-size:20px;', + 'font-weight:bold;font-size:14px;', + ); + // eslint-disable-next-line no-console + console.log( + 'Try React Scan Monitoring to target performance issues in production: https://react-scan.com/monitoring', + ); +}; diff --git a/packages/scan/src/web/utils/lru.ts b/packages/scan/src/web/utils/lru.ts new file mode 100644 index 0000000..a7d4ee5 --- /dev/null +++ b/packages/scan/src/web/utils/lru.ts @@ -0,0 +1,121 @@ +class LRUNode { + public next: LRUNode | undefined; + public prev: LRUNode | undefined; + + constructor( + public key: Key, + public value: Value, + ) {} +} + +/** + * Doubly linked list LRU + */ +export class LRUMap { + private nodes = new Map>(); + + private head: LRUNode | undefined; + private tail: LRUNode | undefined; + + constructor(public limit: number) {} + + has(key: Key) { + return this.nodes.has(key); + } + + get(key: Key): Value | undefined { + const result = this.nodes.get(key); + if (result) { + this.bubble(result); + return result.value; + } + return undefined; + } + + set(key: Key, value: Value): void { + // If node already exists, bubble up + if (this.nodes.has(key)) { + const result = this.nodes.get(key); + if (result) { + this.bubble(result); + } + return; + } + + // create a new node + const node = new LRUNode(key, value); + + // Set node as head + this.insertHead(node); + + // if the map is already at it's limit, remove the old tail + if (this.nodes.size === this.limit && this.tail) { + this.delete(this.tail.key); + } + + this.nodes.set(key, node); + } + + delete(key: Key): void { + const result = this.nodes.get(key); + + if (result) { + this.removeNode(result); + this.nodes.delete(key); + } + } + + private insertHead(node: LRUNode): void { + if (this.head) { + node.next = this.head; + this.head.prev = node; + } else { + this.tail = node; + node.next = undefined; + } + node.prev = undefined; + this.head = node; + } + + private removeNode(node: LRUNode): void { + // Link previous node to next node + if (node.prev) { + node.prev.next = node.next; + } + // and vice versa + if (node.next) { + node.next.prev = node.prev; + } + + if (node === this.tail) { + this.tail = node.prev; + if (this.tail) { + this.tail.next = undefined; + } + } + } + + private insertBefore( + node: LRUNode, + newNode: LRUNode, + ) { + newNode.next = node; + if (node.prev) { + newNode.prev = node.prev; + node.prev.next = newNode; + } else { + newNode.prev = undefined; + this.head = newNode; + } + node.prev = newNode; + } + + private bubble(node: LRUNode) { + if (node.prev) { + // Remove the node + this.removeNode(node); + // swap places with previous node + this.insertBefore(node.prev, node); + } + } +} diff --git a/packages/scan/src/web/utils/outline.ts b/packages/scan/src/web/utils/outline.ts new file mode 100644 index 0000000..f15d734 --- /dev/null +++ b/packages/scan/src/web/utils/outline.ts @@ -0,0 +1,352 @@ +import type { Fiber } from 'bippy'; +import { ReactScanInternals, type OutlineKey } from '~core/index'; +import type { AggregatedChange } from '~core/instrumentation'; +import { throttle } from './helpers'; +import { LRUMap } from './lru'; + +export interface OutlineLabel { + alpha: number; + color: { r: number; g: number; b: number }; + reasons: number; // based on Reason enum + labelText: string; + textWidth: number; + activeOutline: Outline; +} + +const DEFAULT_THROTTLE_TIME = 32; // 2 frames + +const MONO_FONT = + 'Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace'; + +export const getOutlineKey = (rect: DOMRect): string => { + return `${rect.top}-${rect.left}-${rect.width}-${rect.height}`; +}; + +function incrementFrameId() { + requestAnimationFrame(incrementFrameId); +} + +if (typeof window !== 'undefined') { + incrementFrameId(); +} + +export const recalcOutlines = throttle(async () => { + const { activeOutlines } = ReactScanInternals; + const domNodes: Array = []; + for (const activeOutline of activeOutlines.values()) { + domNodes.push(activeOutline.domNode); + } + const rectMap = await batchGetBoundingRects(domNodes); + for (const activeOutline of activeOutlines.values()) { + const rect = rectMap.get(activeOutline.domNode); + if (!rect) { + // we couldn't get a rect for the dom node, but the rect will fade out on its own when we continue + continue; + } + activeOutline.target = rect; + } +}, DEFAULT_THROTTLE_TIME); + +// using intersection observer lets us get the boundingClientRect asynchronously without forcing a reflow. +// The browser can internally optimize the bounding rect query, so this will be faster then meticulously +// Batching getBoundingClientRect at the right time in the browser rendering pipeline. +// batchGetBoundingRects function can return in sub <10ms under good conditions, but may take much longer under poor conditions. +// We interpolate the outline rects to avoid the appearance of jitter +// reference: https://w3c.github.io/IntersectionObserver/ +export const batchGetBoundingRects = ( + elements: Array, +): Promise> => { + return new Promise((resolve) => { + const results = new Map(); + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + const element = entry.target; + const bounds = entry.boundingClientRect; + results.set(element, bounds); + } + observer.disconnect(); + resolve(results); + }); + + for (const element of elements) { + observer.observe(element); + } + }); +}; + +type ComponentName = string; + +export interface Outline { + domNode: Element; + /** Aggregated render info */ // TODO: Flatten AggregatedRender into Outline to avoid re-creating objects + // this render is useless when in active outlines (confirm this rob) + aggregatedRender: AggregatedRender; // maybe we should set this to null when its useless + + /* Active Info- we re-use the Outline object to avoid over-allocing objects, which is why we have a singular aggregatedRender and collection of it (groupedAggregatedRender) */ + alpha: number | null; + totalFrames: number | null; + /* + - Invariant: This scales at a rate of O(unique components rendered at the same (x,y) coordinates) + - renders with the same x/y position but different fibers will be a different fiber -> aggregated render entry. + */ + groupedAggregatedRender: Map | null; + + /* Rects for interpolation */ + current: DOMRect | null; + target: DOMRect | null; + /* This value is computed before the full rendered text is shown, so its only considered an estimate */ + estimatedTextWidth: number | null; // todo: estimated is stupid just make it the actual +} + +export interface AggregatedRender { + name: ComponentName; + frame: number | null; + phase: Set<'mount' | 'update' | 'unmount'>; + time: number | null; + aggregatedCount: number; + forget: boolean; + changes: AggregatedChange; + unnecessary: boolean | null; + didCommit: boolean; + fps: number; + + computedKey: OutlineKey | null; + computedCurrent: DOMRect | null; // reference to dom rect to copy over to new outline made at new position +} + +export const areFibersEqual = (fiberA: Fiber, fiberB: Fiber) => { + if (fiberA === fiberB) { + return true; + } + + if (fiberA.alternate === fiberB) { + return true; + } + + if (fiberA === fiberB.alternate) { + return true; + } + + if ( + fiberA.alternate && + fiberB.alternate && + fiberA.alternate === fiberB.alternate + ) { + return true; + } + return false; +}; + +export const getIsOffscreen = (rect: DOMRect) => { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + return ( + rect.bottom < 0 || + rect.right < 0 || + rect.top > viewportHeight || + rect.left > viewportWidth + ); +}; + +export interface MergedOutlineLabel { + alpha: number; + color: { r: number; g: number; b: number }; + reasons: number; + groupedAggregatedRender: Array; + rect: DOMRect; +} + +// todo: optimize me so this can run always +// note: this can be implemented in nlogn using https://en.wikipedia.org/wiki/Sweep_line_algorithm +export const mergeOverlappingLabels = ( + labels: Array, +): Array => { + if (labels.length > 1500) { + return labels.map((label) => toMergedLabel(label)); + } + + const transformed = labels.map((label) => ({ + original: label, + rect: label.activeOutline.current + ? applyLabelTransform(label.activeOutline.current, label.textWidth) + : new DOMRect(0, 0, 0, 0), // or some other fallback + })); + + transformed.sort((a, b) => a.rect.x - b.rect.x); + + const mergedLabels: Array = []; + const mergedSet = new Set(); + + for (let i = 0; i < transformed.length; i++) { + if (mergedSet.has(i)) continue; + + let currentMerged = toMergedLabel( + transformed[i].original, + transformed[i].rect, + ); + let currentRight = currentMerged.rect.x + currentMerged.rect.width; + + for (let j = i + 1; j < transformed.length; j++) { + if (mergedSet.has(j)) continue; + + if (transformed[j].rect.x > currentRight) { + break; + } + + const nextRect = transformed[j].rect; + const overlapArea = getOverlapArea(currentMerged.rect, nextRect); + if (overlapArea > 0) { + const nextLabel = toMergedLabel(transformed[j].original, nextRect); + currentMerged = mergeTwoLabels(currentMerged, nextLabel); + mergedSet.add(j); + + currentRight = currentMerged.rect.x + currentMerged.rect.width; + } + } + + mergedLabels.push(currentMerged); + } + + return mergedLabels; +}; + +function toMergedLabel( + label: OutlineLabel, + rectOverride?: DOMRect, +): MergedOutlineLabel { + const rect = + rectOverride ?? + (label.activeOutline.current + ? applyLabelTransform(label.activeOutline.current, label.textWidth) + : new DOMRect(0, 0, 0, 0)); // or some other fallback + + const groupedArray = label.activeOutline.groupedAggregatedRender?.values() + ? Array.from(label.activeOutline.groupedAggregatedRender.values()) + : []; + + return { + alpha: label.alpha, + color: label.color, + reasons: label.reasons, + groupedAggregatedRender: groupedArray, + rect, + }; +} + +function mergeTwoLabels( + a: MergedOutlineLabel, + b: MergedOutlineLabel, +): MergedOutlineLabel { + const mergedRect = getBoundingRect(a.rect, b.rect); + + const mergedGrouped = a.groupedAggregatedRender.concat( + b.groupedAggregatedRender, + ); + + const mergedReasons = a.reasons | b.reasons; + + return { + alpha: Math.max(a.alpha, b.alpha), + + ...pickColorClosestToStartStage(a, b), // kinda wrong, should pick color in earliest stage + reasons: mergedReasons, + groupedAggregatedRender: mergedGrouped, + rect: mergedRect, + }; +} + +function getBoundingRect(r1: DOMRect, r2: DOMRect): DOMRect { + const x1 = Math.min(r1.x, r2.x); + const y1 = Math.min(r1.y, r2.y); + const x2 = Math.max(r1.x + r1.width, r2.x + r2.width); + const y2 = Math.max(r1.y + r1.height, r2.y + r2.height); + return new DOMRect(x1, y1, x2 - x1, y2 - y1); +} + +function pickColorClosestToStartStage( + a: MergedOutlineLabel, + b: MergedOutlineLabel, +) { + // stupid hack to always take the gray value when the render is unnecessary (we know the gray value has equal rgb) + if (a.color.r === a.color.g && a.color.g === a.color.b) { + return { color: a.color }; + } + if (b.color.r === b.color.g && b.color.g === b.color.b) { + return { color: b.color }; + } + + return { color: a.color.r <= b.color.r ? a.color : b.color }; +} + +function getOverlapArea(rect1: DOMRect, rect2: DOMRect): number { + if (rect1.right <= rect2.left || rect2.right <= rect1.left) { + return 0; + } + + if (rect1.bottom <= rect2.top || rect2.bottom <= rect1.top) { + return 0; + } + + const xOverlap = + Math.min(rect1.right, rect2.right) - Math.max(rect1.left, rect2.left); + const yOverlap = + Math.min(rect1.bottom, rect2.bottom) - Math.max(rect1.top, rect2.top); + + return xOverlap * yOverlap; +} + +function applyLabelTransform( + rect: DOMRect, + estimatedTextWidth: number, +): DOMRect { + const textHeight = 11; + const labelX = rect.x; + const labelY = rect.y; + return new DOMRect(labelX, labelY, estimatedTextWidth + 4, textHeight + 4); +} + +const textMeasurementCache = new LRUMap(100); + +type MeasuringContext = CanvasTextDrawingStyles & CanvasText; + +let offscreenContext: MeasuringContext; + +function getMeasuringContext(): MeasuringContext { + if (!offscreenContext) { + const dpi = window.devicePixelRatio || 1; + const width = dpi * window.innerWidth; + const height = dpi * window.innerHeight; + + if ('OffscreenCanvas' in window) { + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('unreachable'); + } + offscreenContext = ctx; + } else { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('unreachable'); + } + offscreenContext = ctx as MeasuringContext; + } + } + return offscreenContext; +} + +export const measureTextCached = (text: string): TextMetrics => { + const cached = textMeasurementCache.get(text); + if (cached) { + return cached; + } + const ctx = getMeasuringContext(); + ctx.font = `11px ${MONO_FONT}`; + const metrics = ctx.measureText(text); + textMeasurementCache.set(text, metrics); + return metrics; +}; diff --git a/packages/scan/tailwind.config.mjs b/packages/scan/tailwind.config.mjs new file mode 100644 index 0000000..42de4b5 --- /dev/null +++ b/packages/scan/tailwind.config.mjs @@ -0,0 +1,125 @@ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}'], + corePlugins: { + preflight: true, + }, + darkMode: 'class', + theme: { + extend: { + fontFamily: { + mono: ['Menlo', 'Consolas', 'Monaco', 'Liberation Mono', 'Lucida Console', 'monospace'], + }, + fontSize: { + 'xxs': '0.5rem', + }, + cursor: { + 'nwse-resize': 'nwse-resize', + 'nesw-resize': 'nesw-resize', + 'ns-resize': 'ns-resize', + 'ew-resize': 'ew-resize', + 'move': 'move', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + fadeOut: { + '0%': { opacity: '1' }, + '100%': { opacity: '0' }, + }, + shake: { + '0%': { transform: 'translateX(0)' }, + '25%': { transform: 'translateX(-5px)' }, + '50%': { transform: 'translateX(5px)' }, + '75%': { transform: 'translateX(-5px)' }, + '100%': { transform: 'translateX(0)' }, + }, + rotate: { + '0%': { transform: 'rotate(0deg)' }, + '100%': { transform: 'rotate(360deg)' }, + }, + }, + animation: { + 'fade-in': 'fadeIn ease-in forwards', + 'fade-out': 'fadeOut ease-out forwards', + 'rotate': 'rotate linear infinite', + 'shake': 'shake 0.4s ease-in-out forwards', + }, + zIndex: { + 100: 100, + }, + borderWidth: { + 1: '1px', + }, + }, + }, + safelist: [ + 'cursor-nwse-resize', + 'cursor-nesw-resize', + 'cursor-ns-resize', + 'cursor-ew-resize', + 'cursor-move' + ], + plugins: [ + ({ addUtilities }) => { + const newUtilities = { + '.pointer-events-bounding-box': { + 'pointer-events': 'bounding-box', + }, + }; + addUtilities(newUtilities); + }, + ({ addUtilities }) => { + const newUtilities = { + '.animation-duration-0': { + 'animation-duration': '0s', + }, + '.animation-delay-0': { + 'animation-delay': '0s', + }, + '.animation-duration-100': { + 'animation-duration': '100ms', + }, + '.animation-delay-100': { + 'animation-delay': '100ms', + }, + '.animation-delay-150': { + 'animation-delay': '150ms', + }, + '.animation-duration-200': { + 'animation-duration': '200ms', + }, + '.animation-delay-200': { + 'animation-delay': '200ms', + }, + '.animation-duration-300': { + 'animation-duration': '300ms', + }, + '.animation-delay-300': { + 'animation-delay': '300ms', + }, + '.animation-duration-500': { + 'animation-duration': '500ms', + }, + '.animation-delay-500': { + 'animation-delay': '500ms', + }, + '.animation-duration-700': { + 'animation-duration': '700ms', + }, + '.animation-delay-700': { + 'animation-delay': '700ms', + }, + '.animation-duration-1000': { + 'animation-duration': '1000ms', + }, + '.animation-delay-1000': { + 'animation-delay': '1000ms', + }, + }; + + addUtilities(newUtilities); + }, + ], +}; diff --git a/packages/scan/tsconfig.json b/packages/scan/tsconfig.json new file mode 100644 index 0000000..6b64171 --- /dev/null +++ b/packages/scan/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "jsx": "react-jsx", + "jsxImportSource": "preact", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "~assets/*": [ + "./src/assets/*" + ], + "~core/*": [ + "./src/core/*" + ], + "~web/*": [ + "./src/web/*" + ], + "bippy": [ + "../bippy/src" + ], + "*.worker": [ + "./src/*.worker.ts" + ] + } + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules/@types/react" + ] +} diff --git a/packages/scan/vite.config.ts b/packages/scan/vite.config.ts new file mode 100644 index 0000000..c796f8f --- /dev/null +++ b/packages/scan/vite.config.ts @@ -0,0 +1,136 @@ +import { defineConfig } from 'vite'; +import type { LibraryFormats, LibraryOptions } from 'vite'; +import { resolve } from 'node:path'; +import dts from 'vite-plugin-dts'; +import tsconfigPaths from 'vite-tsconfig-paths'; +import svgSpritePlugin from '@pivanov/vite-plugin-svg-sprite'; + +const banner = `/** +* Copyright 2024 Aiden Bai, Million Software, Inc. +* +* Permission is hereby granted, free of charge, to any person obtaining a copy of this software +* and associated documentation files (the “Software”), to deal in the Software without restriction, +* including without limitation the rights to use, copy, modify, merge, publish, distribute, +* sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +* furnished to do so, subject to the following conditions: +* +* The above copyright notice and this permission notice shall be included in all copies or +* substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/`; + +// Configuration for scan package +const config = { + plugins: [ + dts({ + outDir: '../bippy/dist/scan', + include: ['src/**/*.ts', '../bippy/src/**/*.ts'], + exclude: ['**/*.test.ts', '**/*.test.tsx'], + rollupTypes: true, + copyDtsFiles: false, + insertTypesEntry: true, + compilerOptions: { + emitDeclarationOnly: true, + preserveSymlinks: false, + skipLibCheck: true, + composite: false, + declaration: true, + declarationMap: false, + incremental: false, + baseUrl: '.', + paths: { + '~assets/*': ['./src/assets/*'], + '~core/*': ['./src/core/*'], + '~web/*': ['./src/web/*'], + '*.worker': ['./src/*.worker.ts'], + bippy: ['../bippy/src'], + }, + }, + beforeWriteFile: (filePath, content) => { + if (filePath.endsWith('.d.ts')) { + return { + filePath, + content, + }; + } + return false; + }, + }), + tsconfigPaths(), + svgSpritePlugin({ + iconDirs: [resolve(process.cwd(), 'src/web/assets/svgs')], + symbolId: '[dir]-[name]', + svgDomId: 'svg-sprite', + }), + ], + define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), + 'process.env.VERSION': JSON.stringify(process.env.npm_package_version), + }, + resolve: { + dedupe: ['preact', '@preact/signals'], + mainFields: ['module', 'jsnext:main', 'jsnext', 'main'], + }, + optimizeDeps: { + include: ['preact', '@preact/signals'], + }, + build: { + outDir: '../bippy/dist/scan', + target: 'esnext', + sourcemap: false, + minify: process.env.NODE_ENV === 'production', + emptyOutDir: true, + lib: { + formats: ['es', 'cjs', 'iife'] as LibraryFormats[], + entry: resolve(__dirname, 'src/index.ts'), + name: 'ReactScan', + fileName: (format) => { + if (format === 'es') return 'index.js'; + if (format === 'cjs') return 'index.cjs'; + return 'index.global.js'; + }, + } satisfies LibraryOptions, + rollupOptions: { + external: [ + 'react', + 'react-dom', + 'react-reconciler', + 'next', + 'next/navigation', + 'react-router', + 'react-router-dom', + '@remix-run/react', + ], + treeshake: false, + output: { + banner: `'use client';\n${banner}`, + globals: { + bippy: 'Bippy', + }, + generatedCode: { + constBindings: true, + objectShorthand: true, + }, + preserveModules: false, + minifyInternalExports: true, + compact: true, + hoistTransitiveImports: false, + }, + }, + worker: { + format: 'es' as const, + rollupOptions: { + output: { + entryFileNames: 'offscreen-canvas.worker.js', + }, + }, + }, + }, +}; + +export default defineConfig(config); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca05209..79b1426 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,13 +119,88 @@ importers: version: 5.36.0 tsup: specifier: ^8.2.4 - version: 8.3.5(jiti@2.4.2)(postcss@8.4.49)(typescript@5.7.2)(yaml@2.7.0) + version: 8.3.5(@microsoft/api-extractor@7.48.1(@types/node@22.10.5))(jiti@2.4.2)(postcss@8.4.49)(typescript@5.7.2)(yaml@2.7.0) + vitest: + specifier: ^2.1.8 + version: 2.1.8(@types/node@22.10.5)(@vitest/ui@2.1.8)(happy-dom@15.11.7)(lightningcss@1.28.2)(terser@5.36.0) + + packages/scan: + dependencies: + '@preact/signals': + specifier: ^1.3.1 + version: 1.3.1(preact@10.25.4) + bippy: + specifier: workspace:* + version: link:../bippy + preact: + specifier: ^10.25.1 + version: 10.25.4 + optionalDependencies: + unplugin: + specifier: 2.1.0 + version: 2.1.0 + devDependencies: + '@pivanov/vite-plugin-svg-sprite': + specifier: 3.0.0-rc-0.0.10 + version: 3.0.0-rc-0.0.10(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0)) + '@remix-run/react': + specifier: '*' + version: 2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2) + '@types/rollup': + specifier: ^0.54.0 + version: 0.54.0 + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.4.49) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + next: + specifier: '*' + version: 15.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + postcss: + specifier: ^8.4.49 + version: 8.4.49 + react: + specifier: '*' + version: 19.0.0 + react-dom: + specifier: '*' + version: 19.0.0(react@19.0.0) + react-router: + specifier: ^5.0.0 + version: 5.3.4(react@19.0.0) + react-router-dom: + specifier: ^5.0.0 || ^6.0.0 || ^7.0.0 + version: 7.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + rollup: + specifier: ^4.29.1 + version: 4.29.2 + tailwind-merge: + specifier: ^2.6.0 + version: 2.6.0 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.17 + vite: + specifier: ^6.0.7 + version: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0) + vite-plugin-dts: + specifier: ^4.4.0 + version: 4.4.0(@types/node@22.10.5)(rollup@4.29.2)(typescript@5.7.2)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0)) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.7.2)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0)) vitest: specifier: ^2.1.8 version: 2.1.8(@types/node@22.10.5)(@vitest/ui@2.1.8)(happy-dom@15.11.7)(lightningcss@1.28.2)(terser@5.36.0) packages: + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -308,6 +383,9 @@ packages: '@codesandbox/sandpack-themes@2.0.21': resolution: {integrity: sha512-CMH/MO/dh6foPYb/3eSn2Cu/J3+1+/81Fsaj7VggICkCrmRk0qG5dmgjGAearPTnRkOGORIPHuRqwNXgw0E6YQ==} + '@emnapi/runtime@1.3.1': + resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -740,6 +818,111 @@ packages: cpu: [x64] os: [win32] + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -790,9 +973,90 @@ packages: '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@microsoft/api-extractor-model@7.30.1': + resolution: {integrity: sha512-CTS2PlASJHxVY8hqHORVb1HdECWOEMcMnM6/kDkPr0RZapAFSIHhg9D4jxuE8g+OWYHtPc10LCpmde5pylTRlA==} + + '@microsoft/api-extractor@7.48.1': + resolution: {integrity: sha512-HN9Osa1WxqLM66RaqB5nPAadx+nTIQmY/XtkFdaJvusjG8Tus++QqZtD7KPZDSkhEMGHsYeSyeU8qUzCDUXPjg==} + hasBin: true + + '@microsoft/tsdoc-config@0.17.1': + resolution: {integrity: sha512-UtjIFe0C6oYgTnad4q1QP4qXwLhe6tIpNTRStJ2RZEPIkqQPREAwE5spzVxsdn9UaEMUqhh0AqSx3X4nWAKXWw==} + + '@microsoft/tsdoc@0.15.1': + resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + + '@next/env@15.1.3': + resolution: {integrity: sha512-Q1tXwQCGWyA3ehMph3VO+E6xFPHDKdHFYosadt0F78EObYxPio0S09H9UGYznDe6Wc8eLKLG89GqcFJJDiK5xw==} + + '@next/swc-darwin-arm64@15.1.3': + resolution: {integrity: sha512-aZtmIh8jU89DZahXQt1La0f2EMPt/i7W+rG1sLtYJERsP7GRnNFghsciFpQcKHcGh4dUiyTB5C1X3Dde/Gw8gg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.1.3': + resolution: {integrity: sha512-aw8901rjkVBK5mbq5oV32IqkJg+CQa6aULNlN8zyCWSsePzEG3kpDkAFkkTOh3eJ0p95KbkLyWBzslQKamXsLA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.1.3': + resolution: {integrity: sha512-YbdaYjyHa4fPK4GR4k2XgXV0p8vbU1SZh7vv6El4bl9N+ZSiMfbmqCuCuNU1Z4ebJMumafaz6UCC2zaJCsdzjw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.1.3': + resolution: {integrity: sha512-qgH/aRj2xcr4BouwKG3XdqNu33SDadqbkqB6KaZZkozar857upxKakbRllpqZgWl/NDeSCBYPmUAZPBHZpbA0w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.1.3': + resolution: {integrity: sha512-uzafnTFwZCPN499fNVnS2xFME8WLC9y7PLRs/yqz5lz1X/ySoxfaK2Hbz74zYUdEg+iDZPd8KlsWaw9HKkLEVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.1.3': + resolution: {integrity: sha512-el6GUFi4SiDYnMTTlJJFMU+GHvw0UIFnffP1qhurrN1qJV3BqaSRUjkDUgVV44T6zpw1Lc6u+yn0puDKHs+Sbw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.1.3': + resolution: {integrity: sha512-6RxKjvnvVMM89giYGI1qye9ODsBQpHSHVo8vqA8xGhmRPZHDQUE4jcDbhBwK0GnFMqBnu+XMg3nYukNkmLOLWw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.1.3': + resolution: {integrity: sha512-VId/f5blObG7IodwC5Grf+aYP0O8Saz1/aeU3YcWqNdIUAmFQY3VEPKPaIzfv32F/clvanOb2K2BR5DtDs6XyQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + '@pivanov/vite-plugin-svg-sprite@3.0.0-rc-0.0.10': + resolution: {integrity: sha512-uVgENAQN0CFN4+udNmcHS2+iYDe8QmObzC4jPxLlpJnKvMv7/pIRX279h1huljyk3fd4mE7Jj9SqYDfVfTEHfw==} + peerDependencies: + vite: ^2 || ^3 || ^4 || ^5 || ^6 + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -800,6 +1064,14 @@ packages: '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + '@preact/signals-core@1.8.0': + resolution: {integrity: sha512-OBvUsRZqNmjzCZXWLxkZfhcgT+Fk8DDcT/8vD6a1xhDemodyy87UJRJfASMuSD8FaAIeGgGm85ydXhm7lr4fyA==} + + '@preact/signals@1.3.1': + resolution: {integrity: sha512-nNvSF2O7RDzxp1Rm7SkA5QhN1a2kN8pGE8J5o6UjgDof0F0Vlg6d6HUUVxxqZ1uJrN9xnH2DpL6rpII3Es0SsQ==} + peerDependencies: + preact: 10.x + '@react-hook/intersection-observer@3.1.2': resolution: {integrity: sha512-mWU3BMkmmzyYMSuhO9wu3eJVP21N8TcgYm9bZnTrMwuM818bEk+0NRM3hP+c/TqA9Ln5C7qE53p1H0QMtzYdvQ==} peerDependencies: @@ -810,99 +1082,255 @@ packages: peerDependencies: react: '>=16.8' + '@remix-run/react@2.15.2': + resolution: {integrity: sha512-NAAMsSgoC/sdOgovUewwRCE/RUm3F+MBxxZKfwu3POCNeHaplY5qGkH/y8PUXvdN1EBG7Z0Ko43dyzCfcEy5PA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + + '@remix-run/router@1.21.0': + resolution: {integrity: sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==} + engines: {node: '>=14.0.0'} + + '@remix-run/server-runtime@2.15.2': + resolution: {integrity: sha512-OqiPcvEnnU88B8b1LIWHHkQ3Tz2GDAmQ1RihFNQsbrFKpDsQLkw0lJlnfgKA/uHd0CEEacpfV7C9qqJT3V6Z2g==} + engines: {node: '>=18.0.0'} + peerDependencies: + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + + '@rollup/pluginutils@5.1.4': + resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.28.0': resolution: {integrity: sha512-wLJuPLT6grGZsy34g4N1yRfYeouklTgPhH1gWXCYspenKYD0s3cR99ZevOGw5BexMNywkbV3UkjADisozBmpPQ==} cpu: [arm] os: [android] + '@rollup/rollup-android-arm-eabi@4.29.2': + resolution: {integrity: sha512-s/8RiF4bdmGnc/J0N7lHAr5ZFJj+NdJqJ/Hj29K+c4lEdoVlukzvWXB9XpWZCdakVT0YAw8iyIqUP2iFRz5/jA==} + cpu: [arm] + os: [android] + '@rollup/rollup-android-arm64@4.28.0': resolution: {integrity: sha512-eiNkznlo0dLmVG/6wf+Ifi/v78G4d4QxRhuUl+s8EWZpDewgk7PX3ZyECUXU0Zq/Ca+8nU8cQpNC4Xgn2gFNDA==} cpu: [arm64] os: [android] + '@rollup/rollup-android-arm64@4.29.2': + resolution: {integrity: sha512-mKRlVj1KsKWyEOwR6nwpmzakq6SgZXW4NUHNWlYSiyncJpuXk7wdLzuKdWsRoR1WLbWsZBKvsUCdCTIAqRn9cA==} + cpu: [arm64] + os: [android] + '@rollup/rollup-darwin-arm64@4.28.0': resolution: {integrity: sha512-lmKx9yHsppblnLQZOGxdO66gT77bvdBtr/0P+TPOseowE7D9AJoBw8ZDULRasXRWf1Z86/gcOdpBrV6VDUY36Q==} cpu: [arm64] os: [darwin] + '@rollup/rollup-darwin-arm64@4.29.2': + resolution: {integrity: sha512-vJX+vennGwygmutk7N333lvQ/yKVAHnGoBS2xMRQgXWW8tvn46YWuTDOpKroSPR9BEW0Gqdga2DHqz8Pwk6X5w==} + cpu: [arm64] + os: [darwin] + '@rollup/rollup-darwin-x64@4.28.0': resolution: {integrity: sha512-8hxgfReVs7k9Js1uAIhS6zq3I+wKQETInnWQtgzt8JfGx51R1N6DRVy3F4o0lQwumbErRz52YqwjfvuwRxGv1w==} cpu: [x64] os: [darwin] + '@rollup/rollup-darwin-x64@4.29.2': + resolution: {integrity: sha512-e2rW9ng5O6+Mt3ht8fH0ljfjgSCC6ffmOipiLUgAnlK86CHIaiCdHCzHzmTkMj6vEkqAiRJ7ss6Ibn56B+RE5w==} + cpu: [x64] + os: [darwin] + '@rollup/rollup-freebsd-arm64@4.28.0': resolution: {integrity: sha512-lA1zZB3bFx5oxu9fYud4+g1mt+lYXCoch0M0V/xhqLoGatbzVse0wlSQ1UYOWKpuSu3gyN4qEc0Dxf/DII1bhQ==} cpu: [arm64] os: [freebsd] + '@rollup/rollup-freebsd-arm64@4.29.2': + resolution: {integrity: sha512-/xdNwZe+KesG6XJCK043EjEDZTacCtL4yurMZRLESIgHQdvtNyul3iz2Ab03ZJG0pQKbFTu681i+4ETMF9uE/Q==} + cpu: [arm64] + os: [freebsd] + '@rollup/rollup-freebsd-x64@4.28.0': resolution: {integrity: sha512-aI2plavbUDjCQB/sRbeUZWX9qp12GfYkYSJOrdYTL/C5D53bsE2/nBPuoiJKoWp5SN78v2Vr8ZPnB+/VbQ2pFA==} cpu: [x64] os: [freebsd] + '@rollup/rollup-freebsd-x64@4.29.2': + resolution: {integrity: sha512-eXKvpThGzREuAbc6qxnArHh8l8W4AyTcL8IfEnmx+bcnmaSGgjyAHbzZvHZI2csJ+e0MYddl7DX0X7g3sAuXDQ==} + cpu: [x64] + os: [freebsd] + '@rollup/rollup-linux-arm-gnueabihf@4.28.0': resolution: {integrity: sha512-WXveUPKtfqtaNvpf0iOb0M6xC64GzUX/OowbqfiCSXTdi/jLlOmH0Ba94/OkiY2yTGTwteo4/dsHRfh5bDCZ+w==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-gnueabihf@4.29.2': + resolution: {integrity: sha512-h4VgxxmzmtXLLYNDaUcQevCmPYX6zSj4SwKuzY7SR5YlnCBYsmvfYORXgiU8axhkFCDtQF3RW5LIXT8B14Qykg==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.28.0': resolution: {integrity: sha512-yLc3O2NtOQR67lI79zsSc7lk31xjwcaocvdD1twL64PK1yNaIqCeWI9L5B4MFPAVGEVjH5k1oWSGuYX1Wutxpg==} cpu: [arm] os: [linux] + '@rollup/rollup-linux-arm-musleabihf@4.29.2': + resolution: {integrity: sha512-EObwZ45eMmWZQ1w4N7qy4+G1lKHm6mcOwDa+P2+61qxWu1PtQJ/lz2CNJ7W3CkfgN0FQ7cBUy2tk6D5yR4KeXw==} + cpu: [arm] + os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.28.0': resolution: {integrity: sha512-+P9G9hjEpHucHRXqesY+3X9hD2wh0iNnJXX/QhS/J5vTdG6VhNYMxJ2rJkQOxRUd17u5mbMLHM7yWGZdAASfcg==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-gnu@4.29.2': + resolution: {integrity: sha512-Z7zXVHEXg1elbbYiP/29pPwlJtLeXzjrj4241/kCcECds8Zg9fDfURWbZHRIKrEriAPS8wnVtdl4ZJBvZr325w==} + cpu: [arm64] + os: [linux] + '@rollup/rollup-linux-arm64-musl@4.28.0': resolution: {integrity: sha512-1xsm2rCKSTpKzi5/ypT5wfc+4bOGa/9yI/eaOLW0oMs7qpC542APWhl4A37AENGZ6St6GBMWhCCMM6tXgTIplw==} cpu: [arm64] os: [linux] + '@rollup/rollup-linux-arm64-musl@4.29.2': + resolution: {integrity: sha512-TF4kxkPq+SudS/r4zGPf0G08Bl7+NZcFrUSR3484WwsHgGgJyPQRLCNrQ/R5J6VzxfEeQR9XRpc8m2t7lD6SEQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.29.2': + resolution: {integrity: sha512-kO9Fv5zZuyj2zB2af4KA29QF6t7YSxKrY7sxZXfw8koDQj9bx5Tk5RjH+kWKFKok0wLGTi4bG117h31N+TIBEg==} + cpu: [loong64] + os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.28.0': resolution: {integrity: sha512-zgWxMq8neVQeXL+ouSf6S7DoNeo6EPgi1eeqHXVKQxqPy1B2NvTbaOUWPn/7CfMKL7xvhV0/+fq/Z/J69g1WAQ==} cpu: [ppc64] os: [linux] + '@rollup/rollup-linux-powerpc64le-gnu@4.29.2': + resolution: {integrity: sha512-gIh776X7UCBaetVJGdjXPFurGsdWwHHinwRnC5JlLADU8Yk0EdS/Y+dMO264OjJFo7MXQ5PX4xVFbxrwK8zLqA==} + cpu: [ppc64] + os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.28.0': resolution: {integrity: sha512-VEdVYacLniRxbRJLNtzwGt5vwS0ycYshofI7cWAfj7Vg5asqj+pt+Q6x4n+AONSZW/kVm+5nklde0qs2EUwU2g==} cpu: [riscv64] os: [linux] + '@rollup/rollup-linux-riscv64-gnu@4.29.2': + resolution: {integrity: sha512-YgikssQ5UNq1GoFKZydMEkhKbjlUq7G3h8j6yWXLBF24KyoA5BcMtaOUAXq5sydPmOPEqB6kCyJpyifSpCfQ0w==} + cpu: [riscv64] + os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.28.0': resolution: {integrity: sha512-LQlP5t2hcDJh8HV8RELD9/xlYtEzJkm/aWGsauvdO2ulfl3QYRjqrKW+mGAIWP5kdNCBheqqqYIGElSRCaXfpw==} cpu: [s390x] os: [linux] + '@rollup/rollup-linux-s390x-gnu@4.29.2': + resolution: {integrity: sha512-9ouIR2vFWCyL0Z50dfnon5nOrpDdkTG9lNDs7MRaienQKlTyHcDxplmk3IbhFlutpifBSBr2H4rVILwmMLcaMA==} + cpu: [s390x] + os: [linux] + '@rollup/rollup-linux-x64-gnu@4.28.0': resolution: {integrity: sha512-Nl4KIzteVEKE9BdAvYoTkW19pa7LR/RBrT6F1dJCV/3pbjwDcaOq+edkP0LXuJ9kflW/xOK414X78r+K84+msw==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-gnu@4.29.2': + resolution: {integrity: sha512-ckBBNRN/F+NoSUDENDIJ2U9UWmIODgwDB/vEXCPOMcsco1niTkxTXa6D2Y/pvCnpzaidvY2qVxGzLilNs9BSzw==} + cpu: [x64] + os: [linux] + '@rollup/rollup-linux-x64-musl@4.28.0': resolution: {integrity: sha512-eKpJr4vBDOi4goT75MvW+0dXcNUqisK4jvibY9vDdlgLx+yekxSm55StsHbxUsRxSTt3JEQvlr3cGDkzcSP8bw==} cpu: [x64] os: [linux] + '@rollup/rollup-linux-x64-musl@4.29.2': + resolution: {integrity: sha512-jycl1wL4AgM2aBFJFlpll/kGvAjhK8GSbEmFT5v3KC3rP/b5xZ1KQmv0vQQ8Bzb2ieFQ0kZFPRMbre/l3Bu9JA==} + cpu: [x64] + os: [linux] + '@rollup/rollup-win32-arm64-msvc@4.28.0': resolution: {integrity: sha512-Vi+WR62xWGsE/Oj+mD0FNAPY2MEox3cfyG0zLpotZdehPFXwz6lypkGs5y38Jd/NVSbOD02aVad6q6QYF7i8Bg==} cpu: [arm64] os: [win32] + '@rollup/rollup-win32-arm64-msvc@4.29.2': + resolution: {integrity: sha512-S2V0LlcOiYkNGlRAWZwwUdNgdZBfvsDHW0wYosYFV3c7aKgEVcbonetZXsHv7jRTTX+oY5nDYT4W6B1oUpMNOg==} + cpu: [arm64] + os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.28.0': resolution: {integrity: sha512-kN/Vpip8emMLn/eOza+4JwqDZBL6MPNpkdaEsgUtW1NYN3DZvZqSQrbKzJcTL6hd8YNmFTn7XGWMwccOcJBL0A==} cpu: [ia32] os: [win32] + '@rollup/rollup-win32-ia32-msvc@4.29.2': + resolution: {integrity: sha512-pW8kioj9H5f/UujdoX2atFlXNQ9aCfAxFRaa+mhczwcsusm6gGrSo4z0SLvqLF5LwFqFTjiLCCzGkNK/LE0utQ==} + cpu: [ia32] + os: [win32] + '@rollup/rollup-win32-x64-msvc@4.28.0': resolution: {integrity: sha512-Bvno2/aZT6usSa7lRDL2+hMjVAGjuqaymF1ApZm31JXzniR/hvr14jpU+/z4X6Gt5BPlzosscyJZGUvguXIqeQ==} cpu: [x64] os: [win32] + '@rollup/rollup-win32-x64-msvc@4.29.2': + resolution: {integrity: sha512-p6fTArexECPf6KnOHvJXRpAEq0ON1CBtzG/EY4zw08kCHk/kivBc5vUEtnCFNCHOpJZ2ne77fxwRLIKD4wuW2Q==} + cpu: [x64] + os: [win32] + + '@rushstack/node-core-library@5.10.1': + resolution: {integrity: sha512-BSb/KcyBHmUQwINrgtzo6jiH0HlGFmrUy33vO6unmceuVKTEyL2q+P0fQq2oB5hvXVWOEUhxB2QvlkZluvUEmg==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/rig-package@0.5.3': + resolution: {integrity: sha512-olzSSjYrvCNxUFZowevC3uz8gvKr3WTpHQ7BkpjtRpA3wK+T0ybep/SRUMfr195gBzJm5gaXw0ZMgjIyHqJUow==} + + '@rushstack/terminal@0.14.4': + resolution: {integrity: sha512-NxACqERW0PHq8Rpq1V6v5iTHEwkRGxenjEW+VWqRYQ8T9puUzgmGHmEZUaUEDHAe9Qyvp0/Ew04sAiQw9XjhJg==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + '@rushstack/ts-command-line@4.23.2': + resolution: {integrity: sha512-JJ7XZX5K3ThBBva38aomgsPv1L7FV6XmSOcR6HtM7HDFZJkepqT65imw26h9ggGqMjsY0R9jcl30tzKcVj9aOQ==} + '@stitches/core@1.2.8': resolution: {integrity: sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==} + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tailwindcss/node@4.0.0-beta.8': resolution: {integrity: sha512-ZbicJgFxo83IIH5eBm7CU3K1olsfud7/zg3+yG7P6+fZiufhh8FllM5QOJVxUEJ5zeB1V94Y+hTq5UOfu8ZloA==} @@ -1000,6 +1428,13 @@ packages: '@types/react-dom': optional: true + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + + '@types/argparse@1.0.38': + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1015,6 +1450,9 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -1037,6 +1475,10 @@ packages: '@types/react@18.3.12': resolution: {integrity: sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==} + '@types/rollup@0.54.0': + resolution: {integrity: sha512-oeYztLHhQ98jnr+u2cs1c3tHOGtpzrm9DJlIdEjznwoXWidUbrI+X6ib7zCkPIbB7eJ7VbbKNQ5n/bPnSg6Naw==} + deprecated: This is a stub types definition for rollup (https://github.com/rollup/rollup). rollup provides its own type definitions, so you don't need @types/rollup installed! + '@vitejs/plugin-react@4.3.4': resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1082,11 +1524,68 @@ packages: '@vitest/utils@2.1.8': resolution: {integrity: sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==} + '@volar/language-core@2.4.11': + resolution: {integrity: sha512-lN2C1+ByfW9/JRPpqScuZt/4OrUUse57GLI6TbLgTIqBVemdl1wNcZ1qYGEo2+Gw8coYLgCy7SuKqn6IrQcQgg==} + + '@volar/source-map@2.4.11': + resolution: {integrity: sha512-ZQpmafIGvaZMn/8iuvCFGrW3smeqkq/IIh9F1SdSx9aUl0J4Iurzd6/FhmjNO5g2ejF3rT45dKskgXWiofqlZQ==} + + '@volar/typescript@2.4.11': + resolution: {integrity: sha512-2DT+Tdh88Spp5PyPbqhyoYavYCPDsqbHLFwcUI9K1NlY1YgUJvujGdrqUp0zWxnW7KWNTr3xSpMuv2WnaTKDAw==} + + '@vue/compiler-core@3.5.13': + resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==} + + '@vue/compiler-dom@3.5.13': + resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/language-core@2.1.10': + resolution: {integrity: sha512-DAI289d0K3AB5TUG3xDp9OuQ71CnrujQwJrQnfuZDwo6eGNf0UoRlPuaVNO+Zrn65PC3j0oB2i7mNmVPggeGeQ==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/shared@3.5.13': + resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==} + + '@web3-storage/multipart-parser@1.0.0': + resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==} + acorn@8.14.0: resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} engines: {node: '>=0.4.0'} hasBin: true + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + + ajv@8.13.0: + resolution: {integrity: sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==} + + alien-signals@0.2.2: + resolution: {integrity: sha512-cZIRkbERILsBOXTQmMrxc9hgpxglstn69zm+F1ARf4aPAzdAFYd6sBq87ErO0Fj3DV94tglcyHG5kQz9nDC/8A==} + anser@2.3.0: resolution: {integrity: sha512-pGGR7Nq1K/i9KGs29PvHDXA8AsfZ3OiYRMDClT3FIC085Kbns9CJ7ogq9MEiGnrjd9THOGoh7B+kWzePHzZyJQ==} @@ -1113,6 +1612,16 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -1120,18 +1629,39 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + autoprefixer@10.4.20: + resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - bippy@0.1.1: + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bippy@0.1.1: resolution: {integrity: sha512-dIEIXqR42KJcvScTCza97mVgL3TXYhyHNDCGzqo7RgKYse8wJamLJMkGQ/tH+nOvPHiv9p12w+ukYkBzPV1dVA==} + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + browserslist@4.24.2: resolution: {integrity: sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1149,10 +1679,18 @@ packages: peerDependencies: esbuild: '>=0.18' + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + caniuse-lite@1.0.30001686: resolution: {integrity: sha512-Y7deg0Aergpa24M3qLC5xjNklnKnhsmSyR/V89dLZ1n0ucJIFNs7PgR2Yfa/Zf6W79SbBicgtGxZr2juHkEUIA==} @@ -1168,6 +1706,17 @@ packages: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.0.0: + resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==} + engines: {node: '>=18.17'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + chokidar@4.0.1: resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} engines: {node: '>= 14.16.0'} @@ -1175,6 +1724,9 @@ packages: clean-set@1.1.2: resolution: {integrity: sha512-cA8uCj0qSoG9e0kevyOWXwPaELRPVg5Pxp6WskLMwerx257Zfnh8Nl0JBH59d7wQzij2CK7qEfJQK3RjuKKIug==} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1186,6 +1738,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -1193,9 +1752,22 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + consola@3.2.3: resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1203,6 +1775,14 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -1210,6 +1790,30 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -1217,6 +1821,9 @@ packages: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -1226,6 +1833,15 @@ packages: supports-color: optional: true + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -1239,9 +1855,32 @@ packages: engines: {node: '>=0.10'} hasBin: true + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.1: + resolution: {integrity: sha512-xWXmuRnN9OMP6ptPd2+H0cCbcYBULa5YDTbMm/2lvkWvNA3O4wcW+GvzooqBuNM8yy6pl3VIAeJTUUWUbfI5Fw==} + dotenv@16.4.7: resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} engines: {node: '>=12'} @@ -1258,6 +1897,9 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encoding-sniffer@0.2.0: + resolution: {integrity: sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==} + enhanced-resolve@5.18.0: resolution: {integrity: sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==} engines: {node: '>=10.13.0'} @@ -1309,6 +1951,9 @@ packages: resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} engines: {node: '>=0.10'} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1322,6 +1967,16 @@ packages: ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.18.0: + resolution: {integrity: sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==} + fdir@6.4.2: resolution: {integrity: sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==} peerDependencies: @@ -1333,6 +1988,10 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + find-cache-dir@3.3.2: resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} engines: {node: '>=8'} @@ -1348,6 +2007,13 @@ packages: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1356,10 +2022,21 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true @@ -1373,6 +2050,9 @@ packages: resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} engines: {node: '>=4'} + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1384,9 +2064,30 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + history@4.10.1: + resolution: {integrity: sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1394,6 +2095,10 @@ packages: resolution: {integrity: sha512-yemi4pMf51WKT7khInJqAvsIGzoqYXblnsz0ql8tM+yi1EKYTY1evX4NAbJrLL/Aanr2HyZeluqU+Oi7MGHokw==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -1404,10 +2109,36 @@ packages: intersection-observer@0.10.0: resolution: {integrity: sha512-fn4bQ0Xq8FTej09YC/jqKZwtijpvARlRp6wxL5WTA6yPe2YWSJ5RJh7Nm79rK2qB0wr6iDQzH60XGq5V/7u8YQ==} + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1434,10 +2165,17 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + jiti@2.4.2: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1450,11 +2188,20 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + lightningcss-darwin-arm64@1.28.2: resolution: {integrity: sha512-/8cPSqZiusHSS+WQz0W4NuaqFjquys1x+NsdN/XOHb+idGHJSoJ7SoQTVl3DZuAgtPZwFZgRfb/vd1oi8uX6+g==} engines: {node: '>= 12.0.0'} @@ -1530,6 +2277,10 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + locate-path@5.0.0: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} @@ -1537,6 +2288,13 @@ packages: lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.1.2: resolution: {integrity: sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==} @@ -1546,6 +2304,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -1553,6 +2315,9 @@ packages: magic-string@0.30.15: resolution: {integrity: sha512-zXeaYRgZ6ldS1RJJUrMrYgNJ4fdwnyI6tVqoiIhyCyv5IVTK9BU8Ic2l253GGETQHxI4HNUwhJ3fjDhKqEoaAw==} + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} @@ -1564,10 +2329,27 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.53.0: resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==} engines: {node: '>= 0.6'} + minimatch@3.0.8: + resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==} + minimatch@5.1.6: resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} engines: {node: '>=10'} @@ -1580,6 +2362,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mlly@1.7.3: + resolution: {integrity: sha512-xUsx5n/mN0uQf4V548PKQ+YShA4/IW0KI1dZhrNrPCLG+xizETbHTkOa1f8/xut9JRPp8kQuMnz0oqwkTiLo/A==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1591,6 +2376,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1602,9 +2390,38 @@ packages: next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} + next@15.1.3: + resolution: {integrity: sha512-5igmb8N8AEhWDYzogcJvtcRDU6n4cMGtBklxKD4biYv4LXN8+awc/bbQ2IM2NQHdVPgJ6XumYXfo3hBtErg1DA==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + npm-bundled@2.0.1: resolution: {integrity: sha512-gZLxXdjEzE/+mOstGDqR6b0EkhJ+kM6fxM6vUuckuctuVPh80Q6pw/rSZj9s4Gex9GxWtIicO1pc8DB9KZWudw==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -1618,10 +2435,17 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} hasBin: true + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1643,6 +2467,18 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.2.1: + resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1651,10 +2487,16 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@1.9.0: + resolution: {integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==} + pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} @@ -1665,10 +2507,18 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + picomatch@4.0.2: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} @@ -1677,6 +2527,33 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + pkg-types@1.3.0: + resolution: {integrity: sha512-kS7yWjVFCkIw9hqdJBoMxDdzEngmkr5FXeWZZfQ6GoYacjVnsW6l2CcYW/0ThD0vF4LPJgVYnrg4d0uuhwYQbg==} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -1695,10 +2572,30 @@ packages: yaml: optional: true + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.4.49: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} + preact@10.25.4: + resolution: {integrity: sha512-jLdZDb+Q+odkHJ+MpW/9U5cODzqnB+fy2EiHSZES7ldV5LK7yjlVzTp7R8Xy6W6y75kfK8iWYtFVH7lvjwrCMA==} + prettier@3.4.1: resolution: {integrity: sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==} engines: {node: '>=14'} @@ -1708,6 +2605,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + publint@0.2.12: resolution: {integrity: sha512-YNeUtCVeM4j9nDiTT2OPczmlyzOkIXNtdDZnSuajAxS/nZ6j3t7Vs9SUB4euQNddiltIwu7Tdd3s+hr08fAsMw==} engines: {node: '>=16'} @@ -1717,6 +2617,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-devtools-inline@4.4.0: resolution: {integrity: sha512-ES0GolSrKO8wsKbsEkVeiR/ZAaHQTY4zDh1UW8DImVmm8oaGLl3ijJDvSGe+qDRKPZdPRnDtWWnSvvrgxXdThQ==} @@ -1730,6 +2633,9 @@ packages: peerDependencies: react: ^16.8.4 || ^17.0.0 || ^18.0.0 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -1743,10 +2649,52 @@ packages: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} + react-router-dom@6.28.1: + resolution: {integrity: sha512-YraE27C/RdjcZwl5UCqF/ffXnZDxpJdk9Q6jw38SZHjXs7NNdpViq2l2c7fO7+4uWaEfcwfGCv3RSg4e1By/fQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router-dom@7.1.1: + resolution: {integrity: sha512-vSrQHWlJ5DCfyrhgo0k6zViOe9ToK8uT5XGSmnuC2R3/g261IdIMpZVqfjD6vWSXdnf5Czs4VA/V60oVR6/jnA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@5.3.4: + resolution: {integrity: sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==} + peerDependencies: + react: '>=15' + + react-router@6.28.1: + resolution: {integrity: sha512-2omQTA3rkMljmrvvo6WtewGdVh45SpL9hGiCI9uUrwGGfNFDIvGK4gYJsKlJoNVi6AQZcopSCballL+QGOm7fA==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react-router@7.1.1: + resolution: {integrity: sha512-39sXJkftkKWRZ2oJtHhCxmoCrBCULr/HAH4IT5DHlgu/Q0FCPV0S4Lx+abjDTx/74xoZzNYDYbOZWlJjruyuDQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.0.0: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + readdirp@4.0.2: resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} engines: {node: '>= 14.16.0'} @@ -1754,19 +2702,46 @@ packages: regenerator-runtime@0.14.1: resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pathname@3.0.0: + resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rollup@4.28.0: resolution: {integrity: sha512-G9GOrmgWHBma4YfCcX8PjH0qhXSdH8B4HDE2o4/jaxj93S4DPCIDoLcXz99eWMji4hB29UFCEd7B2gwGJDR9cQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rollup@4.29.2: + resolution: {integrity: sha512-tJXpsEkzsEzyAKIaB3qv3IuvTVcTN7qBw1jL4SPPXM3vzDrJgiLGFY6+HodgFaUHAJ2RYJ94zV5MKRJCoQzQeA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -1774,11 +2749,23 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.5.4: + resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + engines: {node: '>=10'} + hasBin: true + semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1794,6 +2781,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sirv@3.0.0: resolution: {integrity: sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==} engines: {node: '>=18'} @@ -1809,10 +2799,17 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + source-map@0.8.0-beta.0: resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} engines: {node: '>= 8'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1822,9 +2819,17 @@ packages: std-env@3.8.0: resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + strict-event-emitter@0.4.6: resolution: {integrity: sha512-12KWeb+wixJohmnwNFerbyiBrAlq5qJLwIt38etRtKtmmHyDSoGlIqFE9wx+4IwG0aDjI7GV8tc8ZccjWZZtTg==} + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1841,9 +2846,26 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + style-mod@4.1.2: resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + sucrase@3.35.0: resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} engines: {node: '>=16 || 14 >=14.17'} @@ -1856,9 +2878,27 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svgo@3.3.2: + resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==} + engines: {node: '>=14.0.0'} + hasBin: true + tailwind-merge@2.6.0: resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==} + tailwindcss@3.4.17: + resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==} + engines: {node: '>=14.0.0'} + hasBin: true + tailwindcss@4.0.0-beta.8: resolution: {integrity: sha512-21HmdRq9tHDLJZavb2cRBGJxBvRODpwb0/t3tRbMOl65hJE6zG6K6lD6lLS3IOC35u4SOjKjdZiJJi9AuWCf+Q==} @@ -1882,6 +2922,12 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1904,6 +2950,10 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -1918,6 +2968,19 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tsconfck@3.1.4: + resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.3.5: resolution: {integrity: sha512-Tunf6r6m6tnZsG9GYWndg0z8dEV7fD733VBFzFJ5Vcm1FtlXB8xBD/rtrBi2a3YKEV7hHtxiZtW5EAVADoe1pA==} engines: {node: '>=18'} @@ -1937,28 +3000,78 @@ packages: typescript: optional: true + turbo-stream@2.4.0: + resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==} + type@2.7.3: resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==} + typescript@5.4.2: + resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==} + engines: {node: '>=14.17'} + hasBin: true + typescript@5.7.2: resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} engines: {node: '>=14.17'} hasBin: true + ufo@1.5.4: + resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + undici@6.21.0: + resolution: {integrity: sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==} + engines: {node: '>=18.17'} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + unplugin@2.1.0: + resolution: {integrity: sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ==} + engines: {node: '>=18.12.0'} + update-browserslist-db@1.1.1: resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + value-equal@1.0.1: + resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} + vite-node@2.1.8: resolution: {integrity: sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true + vite-plugin-dts@4.4.0: + resolution: {integrity: sha512-CJ6phvnnPLF+aFk8Jz2ZcMBLleJ4gKJOXb9We5Kzmsp5bPuD+uMDeVefjFNYSXZ+wdcqnf+Yp2P7oA5hBKQTlQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + typescript: '*' + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite@5.4.11: resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2030,16 +3143,56 @@ packages: yaml: optional: true - vitest@2.1.8: - resolution: {integrity: sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==} - engines: {node: ^18.0.0 || >=20.0.0} + vite@6.0.7: + resolution: {integrity: sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@edge-runtime/vm': '*' - '@types/node': ^18.0.0 || >=20.0.0 - '@vitest/browser': 2.1.8 - '@vitest/ui': 2.1.8 - happy-dom: '*' + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@2.1.8: + resolution: {integrity: sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.8 + '@vitest/ui': 2.1.8 + happy-dom: '*' jsdom: '*' peerDependenciesMeta: '@edge-runtime/vm': @@ -2055,6 +3208,9 @@ packages: jsdom: optional: true + vscode-uri@3.0.8: + resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -2065,10 +3221,21 @@ packages: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + whatwg-url@7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} @@ -2096,6 +3263,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.7.0: resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} engines: {node: '>= 14'} @@ -2103,6 +3273,8 @@ packages: snapshots: + '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -2366,6 +3538,11 @@ snapshots: '@codesandbox/sandpack-themes@2.0.21': {} + '@emnapi/runtime@1.3.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -2582,6 +3759,81 @@ snapshots: '@esbuild/win32-x64@0.24.2': optional: true + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.3.1 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2645,13 +3897,101 @@ snapshots: '@marijn/find-cluster-break@1.0.2': {} + '@microsoft/api-extractor-model@7.30.1(@types/node@22.10.5)': + dependencies: + '@microsoft/tsdoc': 0.15.1 + '@microsoft/tsdoc-config': 0.17.1 + '@rushstack/node-core-library': 5.10.1(@types/node@22.10.5) + transitivePeerDependencies: + - '@types/node' + + '@microsoft/api-extractor@7.48.1(@types/node@22.10.5)': + dependencies: + '@microsoft/api-extractor-model': 7.30.1(@types/node@22.10.5) + '@microsoft/tsdoc': 0.15.1 + '@microsoft/tsdoc-config': 0.17.1 + '@rushstack/node-core-library': 5.10.1(@types/node@22.10.5) + '@rushstack/rig-package': 0.5.3 + '@rushstack/terminal': 0.14.4(@types/node@22.10.5) + '@rushstack/ts-command-line': 4.23.2(@types/node@22.10.5) + lodash: 4.17.21 + minimatch: 3.0.8 + resolve: 1.22.10 + semver: 7.5.4 + source-map: 0.6.1 + typescript: 5.4.2 + transitivePeerDependencies: + - '@types/node' + + '@microsoft/tsdoc-config@0.17.1': + dependencies: + '@microsoft/tsdoc': 0.15.1 + ajv: 8.12.0 + jju: 1.4.0 + resolve: 1.22.10 + + '@microsoft/tsdoc@0.15.1': {} + + '@next/env@15.1.3': {} + + '@next/swc-darwin-arm64@15.1.3': + optional: true + + '@next/swc-darwin-x64@15.1.3': + optional: true + + '@next/swc-linux-arm64-gnu@15.1.3': + optional: true + + '@next/swc-linux-arm64-musl@15.1.3': + optional: true + + '@next/swc-linux-x64-gnu@15.1.3': + optional: true + + '@next/swc-linux-x64-musl@15.1.3': + optional: true + + '@next/swc-win32-arm64-msvc@15.1.3': + optional: true + + '@next/swc-win32-x64-msvc@15.1.3': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.18.0 + '@open-draft/deferred-promise@2.2.0': {} + '@pivanov/vite-plugin-svg-sprite@3.0.0-rc-0.0.10(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0))': + dependencies: + cheerio: 1.0.0 + chokidar: 4.0.1 + svgo: 3.3.2 + typescript: 5.7.2 + vite: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0) + '@pkgjs/parseargs@0.11.0': optional: true '@polka/url@1.0.0-next.28': {} + '@preact/signals-core@1.8.0': {} + + '@preact/signals@1.3.1(preact@10.25.4)': + dependencies: + '@preact/signals-core': 1.8.0 + preact: 10.25.4 + '@react-hook/intersection-observer@3.1.2(react@19.0.0)': dependencies: '@react-hook/passive-layout-effect': 1.2.1(react@19.0.0) @@ -2662,62 +4002,193 @@ snapshots: dependencies: react: 19.0.0 + '@remix-run/react@2.15.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.7.2)': + dependencies: + '@remix-run/router': 1.21.0 + '@remix-run/server-runtime': 2.15.2(typescript@5.7.2) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-router: 6.28.1(react@19.0.0) + react-router-dom: 6.28.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + turbo-stream: 2.4.0 + optionalDependencies: + typescript: 5.7.2 + + '@remix-run/router@1.21.0': {} + + '@remix-run/server-runtime@2.15.2(typescript@5.7.2)': + dependencies: + '@remix-run/router': 1.21.0 + '@types/cookie': 0.6.0 + '@web3-storage/multipart-parser': 1.0.0 + cookie: 0.6.0 + set-cookie-parser: 2.7.1 + source-map: 0.7.4 + turbo-stream: 2.4.0 + optionalDependencies: + typescript: 5.7.2 + + '@rollup/pluginutils@5.1.4(rollup@4.29.2)': + dependencies: + '@types/estree': 1.0.6 + estree-walker: 2.0.2 + picomatch: 4.0.2 + optionalDependencies: + rollup: 4.29.2 + '@rollup/rollup-android-arm-eabi@4.28.0': optional: true + '@rollup/rollup-android-arm-eabi@4.29.2': + optional: true + '@rollup/rollup-android-arm64@4.28.0': optional: true + '@rollup/rollup-android-arm64@4.29.2': + optional: true + '@rollup/rollup-darwin-arm64@4.28.0': optional: true + '@rollup/rollup-darwin-arm64@4.29.2': + optional: true + '@rollup/rollup-darwin-x64@4.28.0': optional: true + '@rollup/rollup-darwin-x64@4.29.2': + optional: true + '@rollup/rollup-freebsd-arm64@4.28.0': optional: true + '@rollup/rollup-freebsd-arm64@4.29.2': + optional: true + '@rollup/rollup-freebsd-x64@4.28.0': optional: true + '@rollup/rollup-freebsd-x64@4.29.2': + optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.28.0': optional: true + '@rollup/rollup-linux-arm-gnueabihf@4.29.2': + optional: true + '@rollup/rollup-linux-arm-musleabihf@4.28.0': optional: true + '@rollup/rollup-linux-arm-musleabihf@4.29.2': + optional: true + '@rollup/rollup-linux-arm64-gnu@4.28.0': optional: true + '@rollup/rollup-linux-arm64-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-arm64-musl@4.28.0': optional: true + '@rollup/rollup-linux-arm64-musl@4.29.2': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.28.0': optional: true + '@rollup/rollup-linux-powerpc64le-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-riscv64-gnu@4.28.0': optional: true + '@rollup/rollup-linux-riscv64-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-s390x-gnu@4.28.0': optional: true + '@rollup/rollup-linux-s390x-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-x64-gnu@4.28.0': optional: true + '@rollup/rollup-linux-x64-gnu@4.29.2': + optional: true + '@rollup/rollup-linux-x64-musl@4.28.0': optional: true + '@rollup/rollup-linux-x64-musl@4.29.2': + optional: true + '@rollup/rollup-win32-arm64-msvc@4.28.0': optional: true + '@rollup/rollup-win32-arm64-msvc@4.29.2': + optional: true + '@rollup/rollup-win32-ia32-msvc@4.28.0': optional: true + '@rollup/rollup-win32-ia32-msvc@4.29.2': + optional: true + '@rollup/rollup-win32-x64-msvc@4.28.0': optional: true + '@rollup/rollup-win32-x64-msvc@4.29.2': + optional: true + + '@rushstack/node-core-library@5.10.1(@types/node@22.10.5)': + dependencies: + ajv: 8.13.0 + ajv-draft-04: 1.0.0(ajv@8.13.0) + ajv-formats: 3.0.1(ajv@8.13.0) + fs-extra: 7.0.1 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.10 + semver: 7.5.4 + optionalDependencies: + '@types/node': 22.10.5 + + '@rushstack/rig-package@0.5.3': + dependencies: + resolve: 1.22.10 + strip-json-comments: 3.1.1 + + '@rushstack/terminal@0.14.4(@types/node@22.10.5)': + dependencies: + '@rushstack/node-core-library': 5.10.1(@types/node@22.10.5) + supports-color: 8.1.1 + optionalDependencies: + '@types/node': 22.10.5 + + '@rushstack/ts-command-line@4.23.2(@types/node@22.10.5)': + dependencies: + '@rushstack/terminal': 0.14.4(@types/node@22.10.5) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + '@stitches/core@1.2.8': {} + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.0.0-beta.8': dependencies: enhanced-resolve: 5.18.0 @@ -2800,6 +4271,10 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 19.0.2(@types/react@18.3.12) + '@trysound/sax@0.2.0': {} + + '@types/argparse@1.0.38': {} + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -2823,6 +4298,8 @@ snapshots: dependencies: '@babel/types': 7.26.0 + '@types/cookie@0.6.0': {} + '@types/estree@1.0.6': {} '@types/node@22.10.5': @@ -2844,6 +4321,10 @@ snapshots: '@types/prop-types': 15.7.13 csstype: 3.1.3 + '@types/rollup@0.54.0': + dependencies: + rollup: 4.29.2 + '@vitejs/plugin-react@4.3.4(vite@6.0.2(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0))': dependencies: '@babel/core': 7.26.0 @@ -2922,8 +4403,79 @@ snapshots: loupe: 3.1.2 tinyrainbow: 1.2.0 + '@volar/language-core@2.4.11': + dependencies: + '@volar/source-map': 2.4.11 + + '@volar/source-map@2.4.11': {} + + '@volar/typescript@2.4.11': + dependencies: + '@volar/language-core': 2.4.11 + path-browserify: 1.0.1 + vscode-uri: 3.0.8 + + '@vue/compiler-core@3.5.13': + dependencies: + '@babel/parser': 7.26.2 + '@vue/shared': 3.5.13 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.13': + dependencies: + '@vue/compiler-core': 3.5.13 + '@vue/shared': 3.5.13 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/language-core@2.1.10(typescript@5.7.2)': + dependencies: + '@volar/language-core': 2.4.11 + '@vue/compiler-dom': 3.5.13 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.13 + alien-signals: 0.2.2 + minimatch: 9.0.5 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.7.2 + + '@vue/shared@3.5.13': {} + + '@web3-storage/multipart-parser@1.0.0': {} + acorn@8.14.0: {} + ajv-draft-04@1.0.0(ajv@8.13.0): + optionalDependencies: + ajv: 8.13.0 + + ajv-formats@3.0.1(ajv@8.13.0): + optionalDependencies: + ajv: 8.13.0 + + ajv@8.12.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + ajv@8.13.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + alien-signals@0.2.2: {} + anser@2.3.0: {} ansi-regex@5.0.1: {} @@ -2940,22 +4492,56 @@ snapshots: any-promise@1.3.0: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + aria-query@5.3.0: dependencies: dequal: 2.0.3 assertion-error@2.0.1: {} - balanced-match@1.0.2: {} - + autoprefixer@10.4.20(postcss@8.4.49): + dependencies: + browserslist: 4.24.2 + caniuse-lite: 1.0.30001686 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + + balanced-match@1.0.2: {} + base64-js@1.5.1: {} + binary-extensions@2.3.0: {} + bippy@0.1.1: {} + boolbase@1.0.0: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + brace-expansion@2.0.1: dependencies: balanced-match: 1.0.2 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.24.2: dependencies: caniuse-lite: 1.0.30001686 @@ -2975,8 +4561,14 @@ snapshots: esbuild: 0.24.2 load-tsconfig: 0.2.5 + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + cac@6.7.14: {} + camelcase-css@2.0.1: {} + caniuse-lite@1.0.30001686: {} chai@5.1.2: @@ -2994,12 +4586,49 @@ snapshots: check-error@2.1.1: {} + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.1 + + cheerio@1.0.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.1 + encoding-sniffer: 0.2.0 + htmlparser2: 9.1.0 + parse5: 7.2.1 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 6.21.0 + whatwg-mimetype: 4.0.0 + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + chokidar@4.0.1: dependencies: readdirp: 4.0.2 clean-set@1.1.2: {} + client-only@0.0.1: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -3008,16 +4637,40 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + optional: true + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + commander@2.20.3: {} commander@4.1.1: {} + commander@7.2.0: {} + commondir@1.0.1: {} + compare-versions@6.1.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + consola@3.2.3: {} convert-source-map@2.0.0: {} + cookie@0.6.0: {} + + cookie@1.0.2: {} + crelt@1.0.6: {} cross-spawn@7.0.6: @@ -3026,6 +4679,32 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.2.1 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + + css-what@6.1.0: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + csstype@3.1.3: {} d@1.0.2: @@ -3033,18 +4712,49 @@ snapshots: es5-ext: 0.10.64 type: 2.7.3 + de-indent@1.0.2: {} + debug@4.3.7: dependencies: ms: 2.1.3 + debug@4.4.0: + dependencies: + ms: 2.1.3 + deep-eql@5.0.2: {} dequal@2.0.3: {} detect-libc@1.0.3: {} + detect-libc@2.0.3: + optional: true + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.1: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dotenv@16.4.7: {} eastasianwidth@0.2.0: {} @@ -3055,6 +4765,11 @@ snapshots: emoji-regex@9.2.2: {} + encoding-sniffer@0.2.0: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + enhanced-resolve@5.18.0: dependencies: graceful-fs: 4.2.11 @@ -3179,6 +4894,8 @@ snapshots: event-emitter: 0.3.5 type: 2.7.3 + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.6 @@ -3194,12 +4911,30 @@ snapshots: dependencies: type: 2.7.3 + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.18.0: + dependencies: + reusify: 1.0.4 + fdir@6.4.2(picomatch@4.0.2): optionalDependencies: picomatch: 4.0.2 fflate@0.8.2: {} + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + find-cache-dir@3.3.2: dependencies: commondir: 1.0.1 @@ -3218,13 +4953,31 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fraction.js@4.3.7: {} + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + fs.realpath@1.0.0: {} fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob@10.4.5: dependencies: foreground-child: 3.3.0 @@ -3244,6 +4997,8 @@ snapshots: globals@11.12.0: {} + globrex@0.1.2: {} + graceful-fs@4.2.11: {} happy-dom@15.11.7: @@ -3254,14 +5009,46 @@ snapshots: has-flag@4.0.0: {} + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + he@1.2.0: {} + + history@4.10.1: + dependencies: + '@babel/runtime': 7.26.0 + loose-envify: 1.4.0 + resolve-pathname: 3.0.0 + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + value-equal: 1.0.1 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + html-escaper@2.0.2: {} + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.1 + entities: 4.5.0 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore-walk@5.0.1: dependencies: minimatch: 5.1.6 + import-lazy@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -3271,8 +5058,29 @@ snapshots: intersection-observer@0.10.0: {} + is-arrayish@0.3.2: + optional: true + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + isarray@0.0.1: {} + isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -3312,16 +5120,28 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jiti@1.21.7: {} + jiti@2.4.2: {} + jju@1.4.0: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} jsesc@3.0.2: {} + json-schema-traverse@1.0.0: {} + json5@2.2.3: {} + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + kolorist@1.8.0: {} + lightningcss-darwin-arm64@1.28.2: optional: true @@ -3373,12 +5193,23 @@ snapshots: load-tsconfig@0.2.5: {} + local-pkg@0.5.1: + dependencies: + mlly: 1.7.3 + pkg-types: 1.3.0 + locate-path@5.0.0: dependencies: p-locate: 4.1.0 lodash.sortby@4.7.0: {} + lodash@4.17.21: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.1.2: {} lru-cache@10.4.3: {} @@ -3387,12 +5218,20 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lz-string@1.5.0: {} magic-string@0.30.15: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + magicast@0.3.5: dependencies: '@babel/parser': 7.26.2 @@ -3407,8 +5246,23 @@ snapshots: dependencies: semver: 7.6.3 + mdn-data@2.0.28: {} + + mdn-data@2.0.30: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + mime-db@1.53.0: {} + minimatch@3.0.8: + dependencies: + brace-expansion: 1.1.11 + minimatch@5.1.6: dependencies: brace-expansion: 2.0.1 @@ -3419,12 +5273,21 @@ snapshots: minipass@7.1.2: {} + mlly@1.7.3: + dependencies: + acorn: 8.14.0 + pathe: 1.1.2 + pkg-types: 1.3.0 + ufo: 1.5.4 + mri@1.2.0: {} mrmime@2.0.0: {} ms@2.1.3: {} + muggle-string@0.4.1: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -3435,8 +5298,37 @@ snapshots: next-tick@1.1.0: {} + next@15.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@next/env': 15.1.3 + '@swc/counter': 0.1.3 + '@swc/helpers': 0.5.15 + busboy: 1.6.0 + caniuse-lite: 1.0.30001686 + postcss: 8.4.31 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + styled-jsx: 5.1.6(react@19.0.0) + optionalDependencies: + '@next/swc-darwin-arm64': 15.1.3 + '@next/swc-darwin-x64': 15.1.3 + '@next/swc-linux-arm64-gnu': 15.1.3 + '@next/swc-linux-arm64-musl': 15.1.3 + '@next/swc-linux-x64-gnu': 15.1.3 + '@next/swc-linux-x64-musl': 15.1.3 + '@next/swc-win32-arm64-msvc': 15.1.3 + '@next/swc-win32-x64-msvc': 15.1.3 + sharp: 0.33.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + node-releases@2.0.18: {} + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + npm-bundled@2.0.1: dependencies: npm-normalize-package-bin: 2.0.0 @@ -3450,8 +5342,14 @@ snapshots: npm-bundled: 2.0.1 npm-normalize-package-bin: 2.0.0 + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + object-assign@4.1.1: {} + object-hash@3.0.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -3470,29 +5368,79 @@ snapshots: package-json-from-dist@1.0.1: {} + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.2.1 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.2.1 + + parse5@7.2.1: + dependencies: + entities: 4.5.0 + + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} + path-parse@1.0.7: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 minipass: 7.1.2 + path-to-regexp@1.9.0: + dependencies: + isarray: 0.0.1 + pathe@1.1.2: {} pathval@2.0.0: {} picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.2: {} + pify@2.3.0: {} + pirates@4.0.6: {} pkg-dir@4.2.0: dependencies: find-up: 4.1.0 + pkg-types@1.3.0: + dependencies: + confbox: 0.1.8 + mlly: 1.7.3 + pathe: 1.1.2 + + postcss-import@15.1.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.10 + + postcss-js@4.0.1(postcss@8.4.49): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.49 + + postcss-load-config@4.0.2(postcss@8.4.49): + dependencies: + lilconfig: 3.1.3 + yaml: 2.7.0 + optionalDependencies: + postcss: 8.4.49 + postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.4.49)(yaml@2.7.0): dependencies: lilconfig: 3.1.3 @@ -3501,12 +5449,32 @@ snapshots: postcss: 8.4.49 yaml: 2.7.0 + postcss-nested@6.2.0(postcss@8.4.49): + dependencies: + postcss: 8.4.49 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.8 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.4.49: dependencies: nanoid: 3.3.8 picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.25.4: {} + prettier@3.4.1: {} pretty-format@27.5.1: @@ -3515,6 +5483,12 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + publint@0.2.12: dependencies: npm-packlist: 5.1.3 @@ -3523,6 +5497,8 @@ snapshots: punycode@2.3.1: {} + queue-microtask@1.2.3: {} + react-devtools-inline@4.4.0: dependencies: es6-symbol: 3.1.4 @@ -3536,6 +5512,8 @@ snapshots: dependencies: react: 19.0.0 + react-is@16.13.1: {} + react-is@17.0.2: {} react-reconciler@0.31.0(react@19.0.0): @@ -3545,14 +5523,75 @@ snapshots: react-refresh@0.14.2: {} + react-router-dom@6.28.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@remix-run/router': 1.21.0 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-router: 6.28.1(react@19.0.0) + + react-router-dom@7.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + react-router: 7.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + + react-router@5.3.4(react@19.0.0): + dependencies: + '@babel/runtime': 7.26.0 + history: 4.10.1 + hoist-non-react-statics: 3.3.2 + loose-envify: 1.4.0 + path-to-regexp: 1.9.0 + prop-types: 15.8.1 + react: 19.0.0 + react-is: 16.13.1 + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + react-router@6.28.1(react@19.0.0): + dependencies: + '@remix-run/router': 1.21.0 + react: 19.0.0 + + react-router@7.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + '@types/cookie': 0.6.0 + cookie: 1.0.2 + react: 19.0.0 + set-cookie-parser: 2.7.1 + turbo-stream: 2.4.0 + optionalDependencies: + react-dom: 19.0.0(react@19.0.0) + react@19.0.0: {} + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + readdirp@4.0.2: {} regenerator-runtime@0.14.1: {} + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} + resolve-pathname@3.0.0: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.0.4: {} + rollup@4.28.0: dependencies: '@types/estree': 1.0.6 @@ -3577,16 +5616,80 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.28.0 fsevents: 2.3.3 + rollup@4.29.2: + dependencies: + '@types/estree': 1.0.6 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.29.2 + '@rollup/rollup-android-arm64': 4.29.2 + '@rollup/rollup-darwin-arm64': 4.29.2 + '@rollup/rollup-darwin-x64': 4.29.2 + '@rollup/rollup-freebsd-arm64': 4.29.2 + '@rollup/rollup-freebsd-x64': 4.29.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.29.2 + '@rollup/rollup-linux-arm-musleabihf': 4.29.2 + '@rollup/rollup-linux-arm64-gnu': 4.29.2 + '@rollup/rollup-linux-arm64-musl': 4.29.2 + '@rollup/rollup-linux-loongarch64-gnu': 4.29.2 + '@rollup/rollup-linux-powerpc64le-gnu': 4.29.2 + '@rollup/rollup-linux-riscv64-gnu': 4.29.2 + '@rollup/rollup-linux-s390x-gnu': 4.29.2 + '@rollup/rollup-linux-x64-gnu': 4.29.2 + '@rollup/rollup-linux-x64-musl': 4.29.2 + '@rollup/rollup-win32-arm64-msvc': 4.29.2 + '@rollup/rollup-win32-ia32-msvc': 4.29.2 + '@rollup/rollup-win32-x64-msvc': 4.29.2 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + sade@1.8.1: dependencies: mri: 1.2.0 + safer-buffer@2.1.2: {} + scheduler@0.25.0: {} semver@6.3.1: {} + semver@7.5.4: + dependencies: + lru-cache: 6.0.0 + semver@7.6.3: {} + set-cookie-parser@2.7.1: {} + + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.0.3 + semver: 7.6.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3597,6 +5700,11 @@ snapshots: signal-exit@4.1.0: {} + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + optional: true + sirv@3.0.0: dependencies: '@polka/url': 1.0.0-next.28 @@ -3612,10 +5720,14 @@ snapshots: source-map@0.6.1: {} + source-map@0.7.4: {} + source-map@0.8.0-beta.0: dependencies: whatwg-url: 7.1.0 + sprintf-js@1.0.3: {} + stackback@0.0.2: {} static-browser-server@1.0.3: @@ -3627,8 +5739,12 @@ snapshots: std-env@3.8.0: {} + streamsearch@1.1.0: {} + strict-event-emitter@0.4.6: {} + string-argv@0.3.2: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3649,8 +5765,15 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-json-comments@3.1.1: {} + style-mod@4.1.2: {} + styled-jsx@5.1.6(react@19.0.0): + dependencies: + client-only: 0.0.1 + react: 19.0.0 + sucrase@3.35.0: dependencies: '@jridgewell/gen-mapping': 0.3.5 @@ -3667,8 +5790,51 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svgo@3.3.2: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 5.1.0 + css-tree: 2.3.1 + css-what: 6.1.0 + csso: 5.0.5 + picocolors: 1.1.1 + tailwind-merge@2.6.0: {} + tailwindcss@3.4.17: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.4.49 + postcss-import: 15.1.0(postcss@8.4.49) + postcss-js: 4.0.1(postcss@8.4.49) + postcss-load-config: 4.0.2(postcss@8.4.49) + postcss-nested: 6.2.0(postcss@8.4.49) + postcss-selector-parser: 6.1.2 + resolve: 1.22.10 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + tailwindcss@4.0.0-beta.8: {} tapable@2.2.1: {} @@ -3694,6 +5860,10 @@ snapshots: dependencies: any-promise: 1.3.0 + tiny-invariant@1.3.3: {} + + tiny-warning@1.0.3: {} + tinybench@2.9.0: {} tinyexec@0.3.1: {} @@ -3709,6 +5879,10 @@ snapshots: tinyspy@3.0.2: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + totalist@3.0.1: {} tr46@1.0.1: @@ -3719,7 +5893,13 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.3.5(jiti@2.4.2)(postcss@8.4.49)(typescript@5.7.2)(yaml@2.7.0): + tsconfck@3.1.4(typescript@5.7.2): + optionalDependencies: + typescript: 5.7.2 + + tslib@2.8.1: {} + + tsup@8.3.5(@microsoft/api-extractor@7.48.1(@types/node@22.10.5))(jiti@2.4.2)(postcss@8.4.49)(typescript@5.7.2)(yaml@2.7.0): dependencies: bundle-require: 5.0.0(esbuild@0.24.2) cac: 6.7.14 @@ -3738,6 +5918,7 @@ snapshots: tinyglobby: 0.2.10 tree-kill: 1.2.2 optionalDependencies: + '@microsoft/api-extractor': 7.48.1(@types/node@22.10.5) postcss: 8.4.49 typescript: 5.7.2 transitivePeerDependencies: @@ -3746,19 +5927,42 @@ snapshots: - tsx - yaml + turbo-stream@2.4.0: {} + type@2.7.3: {} - typescript@5.7.2: - optional: true + typescript@5.4.2: {} + + typescript@5.7.2: {} + + ufo@1.5.4: {} undici-types@6.20.0: {} + undici@6.21.0: {} + + universalify@0.1.2: {} + + unplugin@2.1.0: + dependencies: + acorn: 8.14.0 + webpack-virtual-modules: 0.6.2 + optional: true + update-browserslist-db@1.1.1(browserslist@4.24.2): dependencies: browserslist: 4.24.2 escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + value-equal@1.0.1: {} + vite-node@2.1.8(@types/node@22.10.5)(lightningcss@1.28.2)(terser@5.36.0): dependencies: cac: 6.7.14 @@ -3777,11 +5981,41 @@ snapshots: - supports-color - terser + vite-plugin-dts@4.4.0(@types/node@22.10.5)(rollup@4.29.2)(typescript@5.7.2)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0)): + dependencies: + '@microsoft/api-extractor': 7.48.1(@types/node@22.10.5) + '@rollup/pluginutils': 5.1.4(rollup@4.29.2) + '@volar/typescript': 2.4.11 + '@vue/language-core': 2.1.10(typescript@5.7.2) + compare-versions: 6.1.1 + debug: 4.4.0 + kolorist: 1.8.0 + local-pkg: 0.5.1 + magic-string: 0.30.17 + typescript: 5.7.2 + optionalDependencies: + vite: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + + vite-tsconfig-paths@5.1.4(typescript@5.7.2)(vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0)): + dependencies: + debug: 4.3.7 + globrex: 0.1.2 + tsconfck: 3.1.4(typescript@5.7.2) + optionalDependencies: + vite: 6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0) + transitivePeerDependencies: + - supports-color + - typescript + vite@5.4.11(@types/node@22.10.5)(lightningcss@1.28.2)(terser@5.36.0): dependencies: esbuild: 0.21.5 postcss: 8.4.49 - rollup: 4.28.0 + rollup: 4.29.2 optionalDependencies: '@types/node': 22.10.5 fsevents: 2.3.3 @@ -3801,6 +6035,19 @@ snapshots: terser: 5.36.0 yaml: 2.7.0 + vite@6.0.7(@types/node@22.10.5)(jiti@2.4.2)(lightningcss@1.28.2)(terser@5.36.0)(yaml@2.7.0): + dependencies: + esbuild: 0.24.2 + postcss: 8.4.49 + rollup: 4.29.2 + optionalDependencies: + '@types/node': 22.10.5 + fsevents: 2.3.3 + jiti: 2.4.2 + lightningcss: 1.28.2 + terser: 5.36.0 + yaml: 2.7.0 + vitest@2.1.8(@types/node@22.10.5)(@vitest/ui@2.1.8)(happy-dom@15.11.7)(lightningcss@1.28.2)(terser@5.36.0): dependencies: '@vitest/expect': 2.1.8 @@ -3838,14 +6085,25 @@ snapshots: - supports-color - terser + vscode-uri@3.0.8: {} + w3c-keyname@2.2.8: {} webidl-conversions@4.0.2: {} webidl-conversions@7.0.0: {} + webpack-virtual-modules@0.6.2: + optional: true + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} + whatwg-url@7.1.0: dependencies: lodash.sortby: 4.7.0 @@ -3877,5 +6135,6 @@ snapshots: yallist@3.1.1: {} - yaml@2.7.0: - optional: true + yallist@4.0.0: {} + + yaml@2.7.0: {} diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index d8d5b28..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "jsx": "react", - "module": "ESNext", - "target": "ESNext", - "esModuleInterop": true, - "strictNullChecks": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "lib": [ - "esnext", - "dom" - ], - "moduleResolution": "bundler" - }, - "exclude": ["**/node_modules/**", "dist"] -}