Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ui/packages/shared/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"d3-selection": "3.0.0",
"graphviz-wasm": "3.0.2",
"lodash": "^4.17.21",
"lz-string": "^1.5.0",
"moment-timezone": "^0.6.0",
"react-datepicker": "6.9.0",
"react-popper": "^2.3.0",
Expand All @@ -47,6 +48,7 @@
"tsc-watch": "6.3.1"
},
"devDependencies": {
"@types/lz-string": "^1.5.0",
"react": "18.3.1",
"react-dom": "18.3.1"
},
Expand Down
167 changes: 167 additions & 0 deletions ui/packages/shared/components/src/hooks/URLState/compression.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright 2022 The Parca Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import {compressParam, decompressParam, isCompressed} from './compression';

describe('URL Parameter Compression', () => {
describe('compressParam and decompressParam', () => {
it('should compress and decompress a simple string', () => {
const original = 'hello world';
const compressed = compressParam(original);
const decompressed = decompressParam(compressed);

expect(decompressed).toBe(original);
});

it('should compress and decompress a JSON string', () => {
const original = JSON.stringify({
foo: 'bar',
nested: {a: 1, b: 2, c: 3},
array: [1, 2, 3, 4, 5],
});
const compressed = compressParam(original);
const decompressed = decompressParam(compressed);

expect(decompressed).toBe(original);
expect(JSON.parse(decompressed)).toEqual(JSON.parse(original));
});

it('should compress and decompress a large string', () => {
const original = 'a'.repeat(1000);
const compressed = compressParam(original);
const decompressed = decompressParam(compressed);

expect(decompressed).toBe(original);
expect(compressed.length).toBeLessThan(original.length);
});

it('should handle empty strings', () => {
const original = '';
const compressed = compressParam(original);
const decompressed = decompressParam(compressed);

expect(decompressed).toBe(original);
});

it('should add compression prefix when value is compressed', () => {
const original = 'a'.repeat(1000);
const compressed = compressParam(original);

expect(compressed.startsWith('c:')).toBe(true);
});

it('should handle very short strings', () => {
const original = 'short';
const compressed = compressParam(original);
const decompressed = decompressParam(compressed);

// LZ-String can compress even short strings
// Just verify roundtrip works
expect(decompressed).toBe(original);
});

it('should handle backward compatibility with non-compressed values', () => {
const original = 'uncompressed-value';
const decompressed = decompressParam(original);

expect(decompressed).toBe(original);
});
});

describe('isCompressed', () => {
it('should return true for compressed values', () => {
const original = 'a'.repeat(1000);
const compressed = compressParam(original);

expect(isCompressed(compressed)).toBe(true);
});

it('should return false for non-compressed values', () => {
const original = 'short';

expect(isCompressed(original)).toBe(false);
});
});

describe('Compression encoding', () => {
it('should compress and decompress correctly regardless of encoding', () => {
// Use a large string to ensure compression happens
const original = JSON.stringify({
special: '+=/',
unicode: '你好世界',
symbols: '!@#$%^&*()',
repeated: 'a'.repeat(100), // Make it large enough to compress
});
const compressed = compressParam(original);
const decompressed = decompressParam(compressed);

// Verify roundtrip works correctly
expect(compressed.startsWith('c:')).toBe(true);
expect(decompressed).toBe(original);
expect(compressed.length).toBeLessThan(original.length);
});

it('should produce URL-safe output that can be encoded', () => {
// Use a large string to ensure compression happens
const original = JSON.stringify({
filters: ['filter1', 'filter2', 'filter3'],
groupBy: ['label1', 'label2'],
query: 'sum(rate(container_cpu_usage_seconds_total[5m]))',
repeated: 'x'.repeat(100),
});

const compressed = compressParam(original);

// Verify it's actually compressed
expect(compressed.startsWith('c:')).toBe(true);

// Remove the 'c:' prefix to get just the compressed data
const compressedData = compressed.substring(2);

// This should not throw URIError
expect(() => {
const encoded = encodeURIComponent(compressedData);
// Verify it can also be decoded back
const decoded = decodeURIComponent(encoded);
expect(decoded).toBe(compressedData);
}).not.toThrow();

// Verify full roundtrip through URL encoding
const compressedData2 = compressed.substring(2);
const encoded = encodeURIComponent(compressedData2);
const decoded = decodeURIComponent(encoded);
const decompressed = decompressParam(`c:${decoded}`);

expect(decompressed).toBe(original);
});
});

describe('Real-world scenarios', () => {
it('should handle complex query expression', () => {
const original =
'sum(rate(container_cpu_usage_seconds_total{namespace="default"}[5m])) by (pod)';
const compressed = compressParam(original);
const decompressed = decompressParam(compressed);

expect(decompressed).toBe(original);
});

it('should handle array of labels', () => {
const original = 'label1,label2,label3,label4,label5';
const compressed = compressParam(original);
const decompressed = decompressParam(compressed);

expect(decompressed).toBe(original);
});
});
});
102 changes: 102 additions & 0 deletions ui/packages/shared/components/src/hooks/URLState/compression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright 2022 The Parca Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import * as LZString from 'lz-string';

/**
* Compress a string value using LZ-String compression
*
* @param value - The string to compress
* @returns Compressed string (Base64-like encoding)
*/
export const compressParam = (value: string): string => {
const startTime = performance.now();

try {
if (value === '' || value.length === 0) {
return value;
}

// Compress using LZ-String Base64 (returns URL-safe Base64 string)
const compressed = LZString.compressToBase64(value);

// Only use compression if it actually reduces the size
if (compressed.length < value.length) {
// Prefix with 'c:' to indicate this is compressed
const result = `c:${compressed}`;
const totalTime = performance.now() - startTime;
console.log(`[compressParam] ${totalTime.toFixed(2)}ms`);
return result;
}

// If compression doesn't help, return original
return value;
} catch (error) {
const totalTime = performance.now() - startTime;
console.error(`[compressParam] Error after ${totalTime.toFixed(2)}ms:`, error);
// If compression fails, return original value
return value;
}
};

/**
* Decompress a LZ-String compressed string
*
* @param value - The compressed string (with 'c:' prefix) or uncompressed string
* @returns Decompressed string
*/
export const decompressParam = (value: string): string => {
const startTime = performance.now();

try {
if (value === '' || value.length === 0) {
return value;
}

// Check if this is a compressed value (has 'c:' prefix)
if (!value.startsWith('c:')) {
// Not compressed, return as-is
return value;
}

// Remove the 'c:' prefix
const compressed = value.substring(2);

// Decompress using LZ-String Base64
const decompressed = LZString.decompressFromBase64(compressed);

if (decompressed === null || decompressed === undefined) {
throw new Error('Decompression returned null');
}

const totalTime = performance.now() - startTime;
console.log(`[decompressParam] ${totalTime.toFixed(2)}ms`);
return decompressed;
} catch (error) {
const totalTime = performance.now() - startTime;
console.error(`[decompressParam] Error after ${totalTime.toFixed(2)}ms:`, error);
// If decompression fails, try to return the value without the prefix
// This provides backward compatibility
if (value.startsWith('c:')) {
return value.substring(2);
}
return value;
}
};

/**
* Check if a value is compressed (has the compression prefix)
*/
export const isCompressed = (value: string): boolean => {
return value?.startsWith('c:') ?? false;
};
64 changes: 64 additions & 0 deletions ui/packages/shared/components/src/hooks/URLState/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {

import {type NavigateFunction} from '@parca/utilities';

import {compressParam, decompressParam} from './compression';
import {getQueryParamsFromURL, sanitize, type ParamValue} from './utils';

export type ParamValueSetter = (val: ParamValue) => void;
Expand Down Expand Up @@ -156,6 +157,69 @@ export const useURLStateCustom = <T,>(
return [val, setVal];
};

export interface OptionsCompressed<T> {
parse?: (val: string) => T;
stringify?: (val: T) => string;
}

/**
* Hook for URL state with automatic LZ4 compression/decompression
*
* This hook automatically compresses values when writing to the URL and
* decompresses when reading. Useful for large parameter values like JSON objects.
*
* If parse/stringify options are provided, the flow is:
* - Writing: value -> stringify (if provided) -> compress -> URL
* - Reading: URL -> decompress -> parse (if provided) -> value
*
* @example
* // Simple string compression
* const [config, setConfig] = useURLStateCompressed('config');
* setConfig(JSON.stringify({filters: [...], groupBy: [...]}));
*
* @example
* // With custom parse/stringify
* const [filters, setFilters] = useURLStateCompressed<Filter[]>('filters', {
* parse: (str) => JSON.parse(str),
* stringify: (val) => JSON.stringify(val)
* });
*/
export const useURLStateCompressed = <T,>(
param: string,
options?: Options & OptionsCompressed<T>
): [T | undefined, (val: T) => void] => {
const {parse, stringify, ...urlStateOptions} = options ?? {};

return useURLStateCustom<T>(param, {
parse: (val: ParamValue): T => {
if (val == null || val === '') {
return (parse != null ? parse('') : '') as T;
}

// Decompress first
const stringVal = Array.isArray(val) ? val[0] : val;
const decompressed = decompressParam(stringVal);

// Then parse if parser provided
if (parse != null) {
return parse(decompressed);
}

return decompressed as T;
},
stringify: (val: T): ParamValue => {
if (val == null || val === '') return '';

// Stringify first if stringifier provided
const stringified = stringify != null ? stringify(val) : String(val);

// Then compress
return compressParam(stringified);
},
...urlStateOptions,
});
};

export const JSONSerializer = (val: object): string => {
return JSON.stringify(val, (_, v) => (typeof v === 'bigint' ? v.toString() : v));
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import {useCallback, useMemo} from 'react';

import {JSONParser, JSONSerializer, useURLState, useURLStateCustom} from '@parca/components';
import {JSONParser, JSONSerializer, useURLState, useURLStateCompressed} from '@parca/components';
import {USER_PREFERENCES, useUserPreference} from '@parca/hooks';

import {
Expand Down Expand Up @@ -49,7 +49,7 @@ export const useVisualizationState = (): {
USER_PREFERENCES.ALIGN_FUNCTION_NAME.key
);

const [curPathArrow, setCurPathArrow] = useURLStateCustom<CurrentPathFrame[]>('cur_path', {
const [curPathArrow, setCurPathArrow] = useURLStateCompressed<CurrentPathFrame[]>('cur_path', {
parse: JSONParser<CurrentPathFrame[]>,
stringify: JSONSerializer,
defaultValue: '[]',
Expand Down Expand Up @@ -131,7 +131,7 @@ export const useVisualizationState = (): {
);

return {
curPathArrow,
curPathArrow: curPathArrow ?? [],
setCurPathArrow,
colorStackLegend,
colorBy: (colorBy as string) ?? '',
Expand Down
Loading
Loading