mirror of
https://github.com/tradingview/lightweight-charts.git
synced 2024-11-25 16:50:59 +08:00
Merge branch 'master' into cjs-and-ssr
This commit is contained in:
commit
b832193cf2
@ -226,13 +226,20 @@ jobs:
|
||||
environment:
|
||||
NO_SANDBOX: "true"
|
||||
TESTS_REPORT_FILE: "test-results/memleaks/results.xml"
|
||||
RUNNING_ON_CI: "true"
|
||||
steps:
|
||||
- checkout-with-deps
|
||||
- attach_workspace:
|
||||
at: ./
|
||||
# install the required libraries for chrome version used by memlab
|
||||
- run: sudo apt-get update
|
||||
- run: sudo apt-get install ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release wget xdg-utils
|
||||
- run: sleep 5
|
||||
- run: scripts/run-memleaks-tests.sh
|
||||
- store_test_results:
|
||||
path: test-results/
|
||||
- store_artifacts:
|
||||
path: tests/e2e/memleaks/.logs/
|
||||
|
||||
coverage:
|
||||
executor: node16-browsers-executor
|
||||
|
@ -6,6 +6,8 @@
|
||||
!/dist/typings.d.ts
|
||||
/lib/**
|
||||
|
||||
/src/typings/_resize-observer/index.d.ts
|
||||
|
||||
**/node_modules
|
||||
|
||||
/website/docs/api/**
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -34,3 +34,6 @@ debug.html
|
||||
# graphics tests out data
|
||||
/tests/e2e/graphics/.gendata/
|
||||
tests/e2e/coverage/.gendata
|
||||
|
||||
# memleak log output
|
||||
tests/e2e/memleaks/.logs/
|
||||
|
@ -9,7 +9,7 @@ module.exports = [
|
||||
{
|
||||
name: 'ESM',
|
||||
path: 'dist/lightweight-charts.production.mjs',
|
||||
limit: '44.0 KB',
|
||||
limit: '44.3 KB',
|
||||
},
|
||||
{
|
||||
name: 'Standalone-ESM',
|
||||
@ -19,6 +19,6 @@ module.exports = [
|
||||
{
|
||||
name: 'Standalone',
|
||||
path: 'dist/lightweight-charts.standalone.production.js',
|
||||
limit: '45.8 KB',
|
||||
limit: '46.1 KB',
|
||||
},
|
||||
];
|
||||
|
@ -58,6 +58,7 @@
|
||||
"@rollup/plugin-replace": "~5.0.1",
|
||||
"@size-limit/file": "~8.1.0",
|
||||
"@types/chai": "~4.3.4",
|
||||
"@types/glob": "~8.0.0",
|
||||
"@types/mocha": "~10.0.1",
|
||||
"@types/node": "~16.18.9",
|
||||
"@types/pixelmatch": "~5.2.4",
|
||||
@ -85,6 +86,7 @@
|
||||
"markdown-it": "~13.0.1",
|
||||
"markdown-it-anchor": "~8.6.5",
|
||||
"markdownlint-cli": "~0.32.2",
|
||||
"memlab": "~1.1.36",
|
||||
"mocha": "~10.2.0",
|
||||
"npm-run-all": "~4.1.5",
|
||||
"pixelmatch": "~5.3.0",
|
||||
|
@ -2,7 +2,14 @@
|
||||
set -e
|
||||
echo "Preparing"
|
||||
|
||||
npm run build
|
||||
# npm run build
|
||||
|
||||
echo "Memleaks tests"
|
||||
node ./tests/e2e/memleaks/runner.js ./dist/lightweight-charts.standalone.development.js
|
||||
if [[ ! -z "$RUNNING_ON_CI" ]]; then
|
||||
echo "Running on CI, therefore logging mem leaks output to artifact file"
|
||||
rm -rf tests/e2e/memleaks/.logs
|
||||
mkdir tests/e2e/memleaks/.logs
|
||||
node ./tests/e2e/memleaks/runner.js ./dist/lightweight-charts.standalone.development.js > ./tests/e2e/memleaks/.logs/memleaks.txt
|
||||
else
|
||||
node ./tests/e2e/memleaks/runner.js ./dist/lightweight-charts.standalone.development.js
|
||||
fi
|
||||
|
@ -12,6 +12,7 @@ import { watermarkOptionsDefaults } from './watermark-options-defaults';
|
||||
export const chartOptionsDefaults: ChartOptionsInternal = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
autoSize: false,
|
||||
layout: layoutOptionsDefaults,
|
||||
crosshair: crosshairOptionsDefaults,
|
||||
grid: gridOptionsDefaults,
|
||||
|
@ -5,6 +5,7 @@ import { isChromiumBased, isWindows } from '../helpers/browsers';
|
||||
import { Delegate } from '../helpers/delegate';
|
||||
import { IDestroyable } from '../helpers/idestroyable';
|
||||
import { ISubscription } from '../helpers/isubscription';
|
||||
import { warn } from '../helpers/logger';
|
||||
import { DeepPartial } from '../helpers/strict-type-checks';
|
||||
|
||||
import { ChartModel, ChartOptionsInternal } from '../model/chart-model';
|
||||
@ -22,10 +23,12 @@ import { SeriesPlotRow } from '../model/series-data';
|
||||
import { OriginalTime, TimePointIndex } from '../model/time-data';
|
||||
import { TouchMouseEventData } from '../model/touch-mouse-event-data';
|
||||
|
||||
// import { PaneSeparator, SEPARATOR_HEIGHT } from './pane-separator';
|
||||
import { suggestChartSize, suggestPriceScaleWidth, suggestTimeScaleHeight } from './internal-layout-sizes-hints';
|
||||
import { PaneWidget } from './pane-widget';
|
||||
import { TimeAxisWidget } from './time-axis-widget';
|
||||
|
||||
// import { PaneSeparator, SEPARATOR_HEIGHT } from './pane-separator';
|
||||
|
||||
export interface MouseEventParamsImpl {
|
||||
time?: OriginalTime;
|
||||
index?: TimePointIndex;
|
||||
@ -58,8 +61,12 @@ export class ChartWidget implements IDestroyable {
|
||||
private _clicked: Delegate<MouseEventParamsImplSupplier> = new Delegate();
|
||||
private _crosshairMoved: Delegate<MouseEventParamsImplSupplier> = new Delegate();
|
||||
private _onWheelBound: (event: WheelEvent) => void;
|
||||
private _observer: ResizeObserver | null = null;
|
||||
|
||||
private _container: HTMLElement;
|
||||
|
||||
public constructor(container: HTMLElement, options: ChartOptionsInternal) {
|
||||
this._container = container;
|
||||
this._options = options;
|
||||
|
||||
this._element = document.createElement('div');
|
||||
@ -86,29 +93,25 @@ export class ChartWidget implements IDestroyable {
|
||||
this._timeAxisWidget = new TimeAxisWidget(this);
|
||||
this._tableElement.appendChild(this._timeAxisWidget.getElement());
|
||||
|
||||
const usedObserver = options.autoSize && this._installObserver();
|
||||
|
||||
// observer could not fire event immediately for some cases
|
||||
// so we have to set initial size manually
|
||||
let width = this._options.width;
|
||||
let height = this._options.height;
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
// ignore width/height options if observer has actually been used
|
||||
// however respect options if installing resize observer failed
|
||||
if (usedObserver || width === 0 || height === 0) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
// TODO: Fix it better
|
||||
// on Hi-DPI CSS size * Device Pixel Ratio should be integer to avoid smoothing
|
||||
// For chart widget we decreases because we must be inside container.
|
||||
// For time axis this is not important, since it just affects space for pane widgets
|
||||
if (width === 0) {
|
||||
width = Math.floor(containerRect.width);
|
||||
width -= width % 2;
|
||||
}
|
||||
|
||||
if (height === 0) {
|
||||
height = Math.floor(containerRect.height);
|
||||
height -= height % 2;
|
||||
}
|
||||
width = width || containerRect.width;
|
||||
height = height || containerRect.height;
|
||||
}
|
||||
|
||||
// BEWARE: resize must be called BEFORE _syncGuiWithModel (in constructor only)
|
||||
// or after but with adjustSize to properly update time scale
|
||||
this.resize(width, height);
|
||||
if (!usedObserver) {
|
||||
this.resize(width, height);
|
||||
}
|
||||
|
||||
this._syncGuiWithModel();
|
||||
|
||||
@ -165,6 +168,8 @@ export class ChartWidget implements IDestroyable {
|
||||
|
||||
this._crosshairMoved.destroy();
|
||||
this._clicked.destroy();
|
||||
|
||||
this._uninstallObserver();
|
||||
}
|
||||
|
||||
public resize(width: number, height: number, forceRepaint: boolean = false): void {
|
||||
@ -172,11 +177,13 @@ export class ChartWidget implements IDestroyable {
|
||||
return;
|
||||
}
|
||||
|
||||
this._height = height;
|
||||
this._width = width;
|
||||
const sizeHint = suggestChartSize(size({ width, height }));
|
||||
|
||||
const heightStr = height + 'px';
|
||||
const widthStr = width + 'px';
|
||||
this._height = sizeHint.height;
|
||||
this._width = sizeHint.width;
|
||||
|
||||
const heightStr = this._height + 'px';
|
||||
const widthStr = this._width + 'px';
|
||||
|
||||
ensureNotNull(this._element).style.height = heightStr;
|
||||
ensureNotNull(this._element).style.width = widthStr;
|
||||
@ -220,10 +227,7 @@ export class ChartWidget implements IDestroyable {
|
||||
|
||||
this._updateTimeAxisVisibility();
|
||||
|
||||
const width = options.width || this._width;
|
||||
const height = options.height || this._height;
|
||||
|
||||
this.resize(width, height);
|
||||
this._applyAutoSizeOptions(options);
|
||||
}
|
||||
|
||||
public clicked(): ISubscription<MouseEventParamsImplSupplier> {
|
||||
@ -273,6 +277,26 @@ export class ChartWidget implements IDestroyable {
|
||||
return ensureNotNull(priceAxisWidget).getWidth();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
private _applyAutoSizeOptions(options: DeepPartial<ChartOptionsInternal>): void {
|
||||
if (options.autoSize === undefined && this._observer && (options.width !== undefined || options.height !== undefined)) {
|
||||
warn(`You should turn autoSize off explicitly before specifying sizes; try adding options.autoSize: false to new options`);
|
||||
return;
|
||||
}
|
||||
if (options.autoSize && !this._observer) {
|
||||
// installing observer will override resize if successful
|
||||
this._installObserver();
|
||||
}
|
||||
|
||||
if (options.autoSize === false && this._observer !== null) {
|
||||
this._uninstallObserver();
|
||||
}
|
||||
|
||||
if (!options.autoSize && (options.width !== undefined || options.height !== undefined)) {
|
||||
this.resize(options.width || this._width, options.height || this._height);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses the widget's layout (pane and axis child widgets),
|
||||
* draws the screenshot (if rendering context is passed) and returns the screenshot bitmap size
|
||||
@ -385,10 +409,12 @@ export class ChartWidget implements IDestroyable {
|
||||
if (this._isRightAxisVisible()) {
|
||||
rightPriceAxisWidth = Math.max(rightPriceAxisWidth, ensureNotNull(paneWidget.rightPriceAxisWidget()).optimalWidth());
|
||||
}
|
||||
|
||||
totalStretch += paneWidget.stretchFactor();
|
||||
}
|
||||
|
||||
leftPriceAxisWidth = suggestPriceScaleWidth(leftPriceAxisWidth);
|
||||
rightPriceAxisWidth = suggestPriceScaleWidth(rightPriceAxisWidth);
|
||||
|
||||
const width = this._width;
|
||||
const height = this._height;
|
||||
|
||||
@ -399,11 +425,8 @@ export class ChartWidget implements IDestroyable {
|
||||
const separatorsHeight = 0; // separatorHeight * separatorCount;
|
||||
const timeAxisVisible = this._options.timeScale.visible;
|
||||
let timeAxisHeight = timeAxisVisible ? this._timeAxisWidget.optimalHeight() : 0;
|
||||
// TODO: Fix it better
|
||||
// on Hi-DPI CSS size * Device Pixel Ratio should be integer to avoid smoothing
|
||||
if (timeAxisHeight % 2) {
|
||||
timeAxisHeight += 1;
|
||||
}
|
||||
timeAxisHeight = suggestTimeScaleHeight(timeAxisHeight);
|
||||
|
||||
const otherWidgetHeight = separatorsHeight + timeAxisHeight;
|
||||
const totalPaneHeight = height < otherWidgetHeight ? 0 : height - otherWidgetHeight;
|
||||
const stretchPixels = totalPaneHeight / totalStretch;
|
||||
@ -753,6 +776,30 @@ export class ChartWidget implements IDestroyable {
|
||||
private _isRightAxisVisible(): boolean {
|
||||
return this._paneWidgets[0].state().rightPriceScale().options().visible;
|
||||
}
|
||||
|
||||
private _installObserver(): boolean {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (!('ResizeObserver' in window)) {
|
||||
warn('Options contains "autoSize" flag, but the browser does not support ResizeObserver feature. Please provide polyfill.');
|
||||
return false;
|
||||
} else {
|
||||
this._observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
|
||||
const containerEntry = entries.find((entry: ResizeObserverEntry) => entry.target === this._container);
|
||||
if (!containerEntry) {
|
||||
return;
|
||||
}
|
||||
this.resize(containerEntry.contentRect.width, containerEntry.contentRect.height);
|
||||
});
|
||||
this._observer.observe(this._container, { box: 'border-box' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private _uninstallObserver(): void {
|
||||
if (this._observer !== null) {
|
||||
this._observer.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disableSelection(element: HTMLElement): void {
|
||||
|
20
src/gui/internal-layout-sizes-hints.ts
Normal file
20
src/gui/internal-layout-sizes-hints.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Size, size } from 'fancy-canvas';
|
||||
|
||||
// on Hi-DPI CSS size * Device Pixel Ratio should be integer to avoid smoothing
|
||||
// For chart widget we decrease the size because we must be inside container.
|
||||
// For time axis this is not important, since it just affects space for pane widgets
|
||||
export function suggestChartSize(originalSize: Size): Size {
|
||||
const integerWidth = Math.floor(originalSize.width);
|
||||
const integerHeight = Math.floor(originalSize.height);
|
||||
const width = integerWidth - (integerWidth % 2);
|
||||
const height = integerHeight - (integerHeight % 2);
|
||||
return size({ width, height });
|
||||
}
|
||||
|
||||
export function suggestTimeScaleHeight(originalHeight: number): number {
|
||||
return originalHeight + (originalHeight % 2);
|
||||
}
|
||||
|
||||
export function suggestPriceScaleWidth(originalWidth: number): number {
|
||||
return originalWidth + (originalWidth % 2);
|
||||
}
|
@ -28,6 +28,7 @@ import { PriceAxisRendererOptionsProvider } from '../renderers/price-axis-render
|
||||
import { IPriceAxisView } from '../views/price-axis/iprice-axis-view';
|
||||
|
||||
import { createBoundCanvas } from './canvas-utils';
|
||||
import { suggestPriceScaleWidth } from './internal-layout-sizes-hints';
|
||||
import { MouseEventHandler, MouseEventHandlers, TouchMouseEvent } from './mouse-event-handler';
|
||||
import { PaneWidget } from './pane-widget';
|
||||
|
||||
@ -206,7 +207,7 @@ export class PriceAxisWidget implements IDestroyable {
|
||||
ctx.restore();
|
||||
|
||||
const resultTickMarksMaxWidth = tickMarkMaxWidth || Constants.DefaultOptimalWidth;
|
||||
let res = Math.ceil(
|
||||
const res = Math.ceil(
|
||||
rendererOptions.borderSize +
|
||||
rendererOptions.tickLength +
|
||||
rendererOptions.paddingInner +
|
||||
@ -216,8 +217,7 @@ export class PriceAxisWidget implements IDestroyable {
|
||||
);
|
||||
|
||||
// make it even, remove this after migration to perfect fancy canvas
|
||||
res += res % 2;
|
||||
return res;
|
||||
return suggestPriceScaleWidth(res);
|
||||
}
|
||||
|
||||
public setSize(newSize: Size): void {
|
||||
|
@ -247,6 +247,24 @@ export interface ChartOptions {
|
||||
*/
|
||||
height: number;
|
||||
|
||||
/**
|
||||
* Setting this flag to `true` will make the chart watch the chart container's size and automatically resize the chart to fit its container whenever the size changes.
|
||||
*
|
||||
* This feature requires [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver) class to be available in the global scope.
|
||||
* Note that calling code is responsible for providing a polyfill if required. If the global scope does not have `ResizeObserver`, a warning will appear and the flag will be ignored.
|
||||
*
|
||||
* Please pay attention that `autoSize` option and explicit sizes options `width` and `height` don't conflict with one another.
|
||||
* If you specify `autoSize` flag, then `width` and `height` options will be ignored unless `ResizeObserver` has failed. If it fails then the values will be used as fallback.
|
||||
*
|
||||
* The flag `autoSize` could also be set with and unset with `applyOptions` function.
|
||||
* ```js
|
||||
* const chart = LightweightCharts.createChart(document.body, {
|
||||
* autoSize: true,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
autoSize: boolean;
|
||||
|
||||
/**
|
||||
* Watermark options.
|
||||
*
|
||||
|
27
tests/e2e/coverage/test-cases/chart/auto-size.js
Normal file
27
tests/e2e/coverage/test-cases/chart/auto-size.js
Normal file
@ -0,0 +1,27 @@
|
||||
function interactionsToPerform() {
|
||||
return [];
|
||||
}
|
||||
|
||||
function beforeInteractions(container) {
|
||||
const chart = LightweightCharts.createChart(container);
|
||||
|
||||
const mainSeries = chart.addAreaSeries();
|
||||
|
||||
mainSeries.setData(generateLineData());
|
||||
|
||||
chart.applyOptions({
|
||||
autoSize: true,
|
||||
});
|
||||
|
||||
return new Promise(resolve => {
|
||||
requestAnimationFrame(() => {
|
||||
container.style.height = '200px';
|
||||
container.style.width = '250px';
|
||||
requestAnimationFrame(resolve);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function afterInteractions() {
|
||||
return Promise.resolve();
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
window.ignoreMouseMove = true;
|
||||
function generateData() {
|
||||
const res = [];
|
||||
const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0));
|
||||
for (let i = 0; i < 500; ++i) {
|
||||
res.push({
|
||||
time: time.getTime() / 1000,
|
||||
value: i,
|
||||
});
|
||||
|
||||
time.setUTCDate(time.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function runTestCase(container) {
|
||||
const configs = [{}, { width: 500 }, { height: 100 }, { width: 500, height: 100 }];
|
||||
|
||||
const boxes = configs.map((config, i) => {
|
||||
const box = document.createElement('div');
|
||||
|
||||
box.style.position = 'absolute';
|
||||
box.style.top = `${i * 25}%`;
|
||||
box.style.left = 0;
|
||||
box.style.right = 0;
|
||||
box.style.height = '25%';
|
||||
|
||||
container.appendChild(box);
|
||||
|
||||
const chart = LightweightCharts.createChart(box, { autoSize: true, ...config });
|
||||
const mainSeries = chart.addAreaSeries();
|
||||
|
||||
mainSeries.setData(generateData());
|
||||
|
||||
return box;
|
||||
});
|
||||
|
||||
boxes.forEach(box => {
|
||||
box.style.right = 0;
|
||||
box.style.height = '20%';
|
||||
box.style.width = '400';
|
||||
});
|
||||
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, 300);
|
||||
});
|
||||
}
|
31
tests/e2e/memleaks/helpers/get-all-class-names.ts
Normal file
31
tests/e2e/memleaks/helpers/get-all-class-names.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import glob from 'glob';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const globPromise = promisify(glob);
|
||||
|
||||
const srcDir = path.join(__dirname, '..', '..', '..', '..', 'src');
|
||||
|
||||
const classNameRegex = /class\s+([a-zA-Z_][^\W<{]*)/gm;
|
||||
|
||||
/**
|
||||
* This will get all the class names within the source code.
|
||||
* This is used within the mem leaks to ensure that no instances
|
||||
* of these classes exist in the memory heap.
|
||||
*/
|
||||
export async function getClassNames(): Promise<Set<string>> {
|
||||
const sourceFiles = await globPromise(`${srcDir}/**/*.ts`);
|
||||
const classNames: Set<string> = new Set();
|
||||
sourceFiles.forEach((sourceFilePath: string) => {
|
||||
const content = fs.readFileSync(sourceFilePath, { encoding: 'utf-8' });
|
||||
const matches = content.matchAll(classNameRegex);
|
||||
for (const match of matches) {
|
||||
classNames.add(match[1]);
|
||||
}
|
||||
});
|
||||
return classNames;
|
||||
}
|
@ -5,7 +5,7 @@ import * as path from 'path';
|
||||
|
||||
export interface TestCase {
|
||||
name: string;
|
||||
caseContent: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const testCasesDir = path.join(__dirname, '..', 'test-cases');
|
||||
@ -24,6 +24,6 @@ export function getTestCases(): TestCase[] {
|
||||
.filter(isTestCaseFile)
|
||||
.map<TestCase>((testCaseFile: string) => ({
|
||||
name: extractTestCaseName(testCaseFile) as string,
|
||||
caseContent: fs.readFileSync(path.join(testCasesDir, testCaseFile), { encoding: 'utf-8' }),
|
||||
path: path.join(testCasesDir, testCaseFile),
|
||||
}));
|
||||
}
|
||||
|
@ -5,18 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0">
|
||||
<title>Test case page</title>
|
||||
</head>
|
||||
|
||||
<body style="padding: 0; margin: 0;">
|
||||
<div id="container" style="position: absolute; width: 100%; height: 100%;"></div>
|
||||
|
||||
<script type="text/javascript" src="PATH_TO_STANDALONE_MODULE"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
TEST_CASE_SCRIPT
|
||||
</script>
|
||||
|
||||
<script type="text/javascript">
|
||||
window.chart = runTestCase(document.getElementById('container'));
|
||||
</script>
|
||||
<!-- Tests will inject their own code -->
|
||||
<!-- We don't add library.js here because we may want to test the esm version instead -->
|
||||
</body>
|
||||
</html>
|
@ -1,191 +1,98 @@
|
||||
/// <reference types="node" />
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { findLeaks, takeSnapshots } from '@memlab/api';
|
||||
import type { IHeapNode, IScenario } from '@memlab/core';
|
||||
import { expect } from 'chai';
|
||||
import { describe, it } from 'mocha';
|
||||
import puppeteer, { type Browser, type HTTPResponse, type JSHandle, type Page, type PuppeteerLaunchOptions } from 'puppeteer';
|
||||
|
||||
import { getClassNames } from './helpers/get-all-class-names';
|
||||
import { getTestCases } from './helpers/get-test-cases';
|
||||
|
||||
const dummyContent = fs.readFileSync(path.join(__dirname, 'helpers', 'test-page-dummy.html'), { encoding: 'utf-8' });
|
||||
const serverAddressVarName = 'SERVER_ADDRESS';
|
||||
const serverURL: string = process.env[serverAddressVarName] || '';
|
||||
|
||||
function generatePageContent(standaloneBundlePath: string, testCaseCode: string): string {
|
||||
return dummyContent
|
||||
.replace('PATH_TO_STANDALONE_MODULE', standaloneBundlePath)
|
||||
.replace('TEST_CASE_SCRIPT', testCaseCode)
|
||||
;
|
||||
}
|
||||
|
||||
const testStandalonePathEnvKey = 'TEST_STANDALONE_PATH';
|
||||
|
||||
const testStandalonePath: string = process.env[testStandalonePathEnvKey] || '';
|
||||
|
||||
async function getReferencesCount(page: Page, prototypeReference: JSHandle): Promise<number> {
|
||||
const activeRefsHandle = await page.queryObjects(prototypeReference);
|
||||
const activeRefsCount = await (await activeRefsHandle?.getProperty('length'))?.jsonValue();
|
||||
|
||||
await activeRefsHandle.dispose();
|
||||
|
||||
return activeRefsCount;
|
||||
}
|
||||
|
||||
function promisleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve: () => void) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request garbage collection on the page.
|
||||
* **Note:** This is only a request and the page will still decide
|
||||
* when best to perform this action.
|
||||
*/
|
||||
async function requestGarbageCollection(page: Page): Promise<void> {
|
||||
const client = await page.target().createCDPSession();
|
||||
await client.send('HeapProfiler.enable');
|
||||
await client.send('HeapProfiler.collectGarbage');
|
||||
await client.send('HeapProfiler.disable');
|
||||
return page.evaluate(() => {
|
||||
// exposed when '--js-flags="expose-gc"' argument is used with chrome
|
||||
if (window.gc) {
|
||||
window.gc();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Poll the references count on the page until the condition
|
||||
// is satisfied for a specific prototype.
|
||||
async function pollReferencesCount(
|
||||
page: Page,
|
||||
prototype: JSHandle,
|
||||
condition: (currentCount: number) => boolean,
|
||||
timeout: number,
|
||||
actionName?: string,
|
||||
tryCallGarbageCollection?: boolean
|
||||
): Promise<number> {
|
||||
const start = performance.now();
|
||||
let referencesCount = 0;
|
||||
let done = false;
|
||||
do {
|
||||
const duration = performance.now() - start;
|
||||
if (duration > timeout) {
|
||||
throw new Error(`${actionName ? `${actionName}: ` : ''}Timeout exceeded waiting for references count to meet desired condition.`);
|
||||
}
|
||||
referencesCount = await getReferencesCount(page, prototype);
|
||||
done = condition(referencesCount);
|
||||
if (!done) {
|
||||
await promisleep(50);
|
||||
if (tryCallGarbageCollection) {
|
||||
await requestGarbageCollection(page);
|
||||
}
|
||||
}
|
||||
} while (!done);
|
||||
return referencesCount;
|
||||
interface ITestScenario extends IScenario {
|
||||
/**
|
||||
* Set to true if the expected behavior of the test is to fail
|
||||
*/
|
||||
expectFail?: boolean;
|
||||
/**
|
||||
* List of class names which are allowed to leak in this
|
||||
* test.
|
||||
* For example: a cache while the chart is still present
|
||||
*/
|
||||
allowedLeaks?: string[];
|
||||
}
|
||||
|
||||
describe('Memleaks tests', function(): void {
|
||||
// this tests are unstable sometimes.
|
||||
this.retries(5);
|
||||
|
||||
const puppeteerOptions: PuppeteerLaunchOptions = {};
|
||||
puppeteerOptions.args = ['--js-flags="expose-gc"'];
|
||||
if (process.env.NO_SANDBOX) {
|
||||
puppeteerOptions.args.push('--no-sandbox', '--disable-setuid-sandbox');
|
||||
}
|
||||
|
||||
let browser: Browser;
|
||||
|
||||
before(async function(): Promise<void> {
|
||||
this.timeout(40000); // puppeteer may take a while to launch for the first time.
|
||||
expect(testStandalonePath, `path to test standalone module must be passed via ${testStandalonePathEnvKey} env var`)
|
||||
.to.have.length.greaterThan(0);
|
||||
browser = await puppeteer.launch(puppeteerOptions);
|
||||
return Promise.resolve();
|
||||
});
|
||||
this.retries(0);
|
||||
|
||||
const testCases = getTestCases();
|
||||
|
||||
it('number of test cases', () => {
|
||||
// we need to have at least 1 test to check it
|
||||
expect(testCases.length).to.be.greaterThan(0, 'there should be at least 1 test case');
|
||||
expect(testCases.length).to.be.greaterThan(
|
||||
0,
|
||||
'there should be at least 1 test case'
|
||||
);
|
||||
});
|
||||
|
||||
const classNames: Set<string> = new Set();
|
||||
|
||||
for (const testCase of testCases) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
it(testCase.name, async () => {
|
||||
const pageContent = generatePageContent(testStandalonePath, testCase.caseContent);
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 600, height: 600 });
|
||||
|
||||
// set empty page as a content to get initial number
|
||||
// of references
|
||||
await page.setContent('<html><body></body></html>', { waitUntil: 'load' });
|
||||
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', (error: Error) => {
|
||||
errors.push(error.message);
|
||||
});
|
||||
|
||||
page.on('response', (response: HTTPResponse) => {
|
||||
if (!response.ok()) {
|
||||
errors.push(`Network error: ${response.url()} status=${response.status()}`);
|
||||
console.log(`\n\tRunning test: ${testCase.name}`);
|
||||
if (classNames.size < 1) {
|
||||
// async function that we will only call if we don't already have values
|
||||
const names = await getClassNames();
|
||||
for (const name of names) {
|
||||
classNames.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
const getCanvasPrototype = () => {
|
||||
return Promise.resolve(CanvasRenderingContext2D.prototype);
|
||||
};
|
||||
|
||||
const prototype = await page.evaluateHandle(getCanvasPrototype);
|
||||
|
||||
const referencesCountBefore = await getReferencesCount(page, prototype);
|
||||
|
||||
await page.setContent(pageContent, { waitUntil: 'load' });
|
||||
|
||||
if (errors.length !== 0) {
|
||||
throw new Error(`Page has errors:\n${errors.join('\n')}`);
|
||||
}
|
||||
|
||||
// Wait until at least one canvas element has been created.
|
||||
await pollReferencesCount(
|
||||
page,
|
||||
prototype,
|
||||
(count: number) => count > referencesCountBefore,
|
||||
2500,
|
||||
'Creation'
|
||||
expect(classNames.size).to.be.greaterThan(
|
||||
0,
|
||||
'Class name list should contain items'
|
||||
);
|
||||
|
||||
// now remove chart
|
||||
const test = await import(testCase.path);
|
||||
|
||||
await page.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call
|
||||
(window as any).chart.remove();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const scenario = test.scenario as ITestScenario;
|
||||
const expectToFail = scenario.expectFail === true;
|
||||
const allowedLeaks = scenario.allowedLeaks ?? [];
|
||||
if (expectToFail) {
|
||||
console.log(`\t!! This test is expected to fail.`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
|
||||
delete (window as any).chart;
|
||||
const result = await takeSnapshots({
|
||||
scenario: {
|
||||
...scenario,
|
||||
url: () => serverURL,
|
||||
leakFilter: (node: IHeapNode) => {
|
||||
if (
|
||||
(classNames.has(node.name) &&
|
||||
!allowedLeaks.includes(node.name)) ||
|
||||
node.retainedSize > 1_000_000
|
||||
) {
|
||||
if (!expectToFail) {
|
||||
console.log(`LEAK FOUND! Name of constructor: ${node.name} Retained Size: ${node.retainedSize}`);
|
||||
}
|
||||
return true; // This is considered to be a leak.
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
const leaks = await findLeaks(result);
|
||||
|
||||
await requestGarbageCollection(page);
|
||||
|
||||
// Wait until all the created canvas elements have been garbage collected.
|
||||
// Browser could keep references to DOM elements several milliseconds after its actual removing
|
||||
// So we have to wait to be sure all is clear
|
||||
const referencesCountAfter = await pollReferencesCount(
|
||||
page,
|
||||
prototype,
|
||||
(count: number) => count <= referencesCountBefore,
|
||||
10000,
|
||||
'Garbage Collection',
|
||||
true
|
||||
);
|
||||
|
||||
expect(referencesCountAfter).to.be.equal(referencesCountBefore, 'There should not be extra references after removing a chart');
|
||||
if (expectToFail) {
|
||||
expect(leaks.length).to.be.greaterThan(
|
||||
0,
|
||||
'no memory leak detected, but was expected in this case'
|
||||
);
|
||||
} else {
|
||||
expect(leaks.length).to.equal(0, 'memory leak detected');
|
||||
}
|
||||
});
|
||||
}
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
});
|
||||
|
@ -28,22 +28,29 @@ let testStandalonePath = process.argv[2];
|
||||
const hostname = 'localhost';
|
||||
const port = 34567;
|
||||
const httpServerPrefix = `http://${hostname}:${port}/`;
|
||||
let serverAddress = `${httpServerPrefix}index.html`;
|
||||
|
||||
const filesToServe = new Map();
|
||||
|
||||
if (fs.existsSync(testStandalonePath)) {
|
||||
const fileNameToServe = 'test.js';
|
||||
const fileNameToServe = 'index.html';
|
||||
filesToServe.set(fileNameToServe, path.join(__dirname, 'helpers', 'test-page.html'));
|
||||
serverAddress = `${httpServerPrefix}${fileNameToServe}`;
|
||||
}
|
||||
|
||||
if (fs.existsSync(testStandalonePath)) {
|
||||
const fileNameToServe = 'library.js';
|
||||
filesToServe.set(fileNameToServe, path.resolve(testStandalonePath));
|
||||
testStandalonePath = `${httpServerPrefix}${fileNameToServe}`;
|
||||
}
|
||||
|
||||
process.env.TEST_STANDALONE_PATH = testStandalonePath;
|
||||
process.env.SERVER_ADDRESS = serverAddress;
|
||||
|
||||
function runMocha(closeServer) {
|
||||
console.log('Running tests...');
|
||||
const mocha = new Mocha({
|
||||
timeout: 20000,
|
||||
slow: 10000,
|
||||
timeout: 120000,
|
||||
slow: 60000,
|
||||
reporter: mochaConfig.reporter,
|
||||
reporterOptions: mochaConfig._reporterOptions,
|
||||
});
|
||||
|
36
tests/e2e/memleaks/test-cases/control-test-case.js
Normal file
36
tests/e2e/memleaks/test-cases/control-test-case.js
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* This test verifies that memlab doesn't detect instances which
|
||||
* are still in use.
|
||||
*
|
||||
* We are creating a chart before the `action` and don't make any
|
||||
* changes during the `action` and `back` actions therefore the chart will
|
||||
* still be present.
|
||||
*/
|
||||
|
||||
/** @type {import('@memlab/core/dist/lib/Types').IScenario} */
|
||||
const scenario = {
|
||||
setup: async function(page) {
|
||||
await page.addScriptTag({
|
||||
url: 'library.js',
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
window.chart = LightweightCharts.createChart(
|
||||
document.getElementById('container')
|
||||
);
|
||||
const mainSeries = window.chart.addLineSeries();
|
||||
mainSeries.setData([
|
||||
{ time: 0, value: 1 },
|
||||
{ time: 1, value: 2 },
|
||||
]);
|
||||
});
|
||||
},
|
||||
action: async function(page) {
|
||||
await page.evaluate(() => {});
|
||||
},
|
||||
back: async function(page) {
|
||||
await page.evaluate(() => {});
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
exports.scenario = scenario;
|
54
tests/e2e/memleaks/test-cases/expect-fail.js
Normal file
54
tests/e2e/memleaks/test-cases/expect-fail.js
Normal file
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* This test is expected to cause a memory leak. By setting
|
||||
* `expectFail` to `true` we are letting the test runner know
|
||||
* that we are testing that the test does fail.
|
||||
*/
|
||||
|
||||
/** @type {import('@memlab/core/dist/lib/Types').IScenario} */
|
||||
const scenario = {
|
||||
expectFail: true,
|
||||
setup: async function(page) {
|
||||
await page.addScriptTag({
|
||||
url: 'library.js',
|
||||
});
|
||||
},
|
||||
action: async function(page) {
|
||||
await page.evaluate(() => {
|
||||
function generateData() {
|
||||
const res = [];
|
||||
const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0));
|
||||
for (let i = 0; i < 500; ++i) {
|
||||
res.push({
|
||||
time: time.getTime() / 1000,
|
||||
value: i,
|
||||
});
|
||||
time.setUTCDate(time.getUTCDate() + 1);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
window.chart = LightweightCharts.createChart(
|
||||
document.getElementById('container')
|
||||
);
|
||||
const mainSeries = window.chart.addLineSeries({
|
||||
priceFormat: {
|
||||
minMove: 1,
|
||||
precision: 0,
|
||||
},
|
||||
});
|
||||
mainSeries.setData(generateData());
|
||||
});
|
||||
},
|
||||
back: async function(page) {
|
||||
/**
|
||||
* We are not removing the chart here because we
|
||||
* want to 'cause' a leak to test if it is detected correctly.
|
||||
*/
|
||||
await page.evaluate(() => {
|
||||
const container = document.querySelector('#container');
|
||||
container.parentElement.removeChild(container);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
exports.scenario = scenario;
|
97
tests/e2e/memleaks/test-cases/series.js
Normal file
97
tests/e2e/memleaks/test-cases/series.js
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* This test takes an existing chart and adds a variety of series
|
||||
* and then removes these series. Tests if there is any memory leak
|
||||
* resulting from the creation and removal of series.
|
||||
*/
|
||||
|
||||
/** @type {import('@memlab/core/dist/lib/Types').IScenario} */
|
||||
const scenario = {
|
||||
allowedLeaks: [
|
||||
'FormattedLabelsCache',
|
||||
'CrosshairPriceAxisView', // <- We should check and maybe fix this?
|
||||
'PriceAxisViewRenderer', // <- (part of the same leak above)
|
||||
],
|
||||
setup: async function(page) {
|
||||
await page.addScriptTag({
|
||||
url: 'library.js',
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
window.chart = LightweightCharts.createChart(
|
||||
document.getElementById('container')
|
||||
);
|
||||
});
|
||||
},
|
||||
action: async function(page) {
|
||||
await page.evaluate(() => {
|
||||
function generateData() {
|
||||
const res = [];
|
||||
const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0));
|
||||
for (let i = 0; i < 500; ++i) {
|
||||
res.push({
|
||||
time: time.getTime() / 1000,
|
||||
value: i,
|
||||
});
|
||||
time.setUTCDate(time.getUTCDate() + 1);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
function generateBars(count = 500, startDay = 15) {
|
||||
const res = [];
|
||||
const time = new Date(Date.UTC(2018, 0, startDay, 0, 0, 0, 0));
|
||||
for (let i = 0; i < count; ++i) {
|
||||
const step = (i % 20) / 5000;
|
||||
const base = i / 5;
|
||||
|
||||
res.push({
|
||||
time: time.getTime() / 1000,
|
||||
open: base * (1 - step),
|
||||
high: base * (1 + 2 * step),
|
||||
low: base * (1 - 2 * step),
|
||||
close: base * (1 + step),
|
||||
});
|
||||
|
||||
time.setUTCDate(time.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
if (window.chart) {
|
||||
window.lineSeries = window.chart.addLineSeries();
|
||||
window.lineSeries.setData(generateData());
|
||||
window.areaSeries = window.chart.addAreaSeries();
|
||||
window.areaSeries.setData(generateData());
|
||||
window.baselineSeries = window.chart.addBaselineSeries();
|
||||
window.baselineSeries.setData(generateData());
|
||||
window.histogramSeries = window.chart.addHistogramSeries();
|
||||
window.histogramSeries.setData(generateData());
|
||||
|
||||
window.barSeries = window.chart.addBarSeries();
|
||||
window.barSeries.setData(generateBars());
|
||||
window.candlestickSeries = window.chart.addCandlestickSeries();
|
||||
window.candlestickSeries.setData(generateBars());
|
||||
}
|
||||
});
|
||||
},
|
||||
back: async function(page) {
|
||||
await page.evaluate(() => {
|
||||
if (window.chart) {
|
||||
window.chart.removeSeries(window.lineSeries);
|
||||
delete window.lineSeries;
|
||||
window.chart.removeSeries(window.areaSeries);
|
||||
delete window.areaSeries;
|
||||
window.chart.removeSeries(window.baselineSeries);
|
||||
delete window.baselineSeries;
|
||||
window.chart.removeSeries(window.histogramSeries);
|
||||
delete window.histogramSeries;
|
||||
|
||||
window.chart.removeSeries(window.barSeries);
|
||||
delete window.barSeries;
|
||||
window.chart.removeSeries(window.candlestickSeries);
|
||||
delete window.candlestickSeries;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
exports.scenario = scenario;
|
@ -1,43 +1,46 @@
|
||||
function nextBusinessDay(time) {
|
||||
const d = new Date();
|
||||
d.setUTCFullYear(time.year);
|
||||
d.setUTCMonth(time.month - 1);
|
||||
d.setUTCDate(time.day + 1);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return {
|
||||
year: d.getUTCFullYear(),
|
||||
month: d.getUTCMonth() + 1,
|
||||
day: d.getUTCDate(),
|
||||
};
|
||||
}
|
||||
|
||||
function businessDayToTimestamp(time) {
|
||||
const d = new Date();
|
||||
d.setUTCFullYear(time.year);
|
||||
d.setUTCMonth(time.month - 1);
|
||||
d.setUTCDate(time.day);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d.getTime() / 1000;
|
||||
}
|
||||
|
||||
function generateData() {
|
||||
const res = [];
|
||||
let time = nextBusinessDay({ day: 1, month: 1, year: 2018 });
|
||||
for (let i = 0; i < 500; ++i) {
|
||||
time = nextBusinessDay(time);
|
||||
res.push({
|
||||
time: businessDayToTimestamp(time),
|
||||
value: i,
|
||||
/** @type {import('@memlab/core/dist/lib/Types').IScenario} */
|
||||
const scenario = {
|
||||
setup: async function(page) {
|
||||
await page.addScriptTag({
|
||||
url: 'library.js',
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
},
|
||||
action: async function(page) {
|
||||
await page.evaluate(() => {
|
||||
function generateData() {
|
||||
const res = [];
|
||||
const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0));
|
||||
for (let i = 0; i < 500; ++i) {
|
||||
res.push({
|
||||
time: time.getTime() / 1000,
|
||||
value: i,
|
||||
});
|
||||
time.setUTCDate(time.getUTCDate() + 1);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
window.chart = LightweightCharts.createChart(
|
||||
document.getElementById('container')
|
||||
);
|
||||
const mainSeries = window.chart.addLineSeries({
|
||||
priceFormat: {
|
||||
minMove: 1,
|
||||
precision: 0,
|
||||
},
|
||||
});
|
||||
mainSeries.setData(generateData());
|
||||
});
|
||||
},
|
||||
back: async function(page) {
|
||||
await page.evaluate(() => {
|
||||
if (window.chart) {
|
||||
window.chart.remove();
|
||||
delete window.chart;
|
||||
delete window.LightweightCharts;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function runTestCase(container) {
|
||||
const chart = LightweightCharts.createChart(container);
|
||||
|
||||
const mainSeries = chart.addAreaSeries();
|
||||
|
||||
mainSeries.setData(generateData());
|
||||
return chart;
|
||||
}
|
||||
// eslint-disable-next-line no-undef
|
||||
exports.scenario = scenario;
|
||||
|
@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.composite.base.json",
|
||||
"compilerOptions": {
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
|
@ -48,7 +48,13 @@ function httpGetJson(url) {
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(urlString, filePath) {
|
||||
function delay(duration) {
|
||||
return new Promise(resolve => {
|
||||
setTimeout(resolve, duration);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadFile(urlString, filePath, retriesRemaining = 0, attempt = 1) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let file;
|
||||
|
||||
@ -58,11 +64,18 @@ function downloadFile(urlString, filePath) {
|
||||
if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location !== undefined) {
|
||||
// handling redirect
|
||||
url.pathname = response.headers.location;
|
||||
downloadFile(url.toString(), filePath).then(resolve, reject);
|
||||
downloadFile(url.toString(), filePath, retriesRemaining).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode && (response.statusCode < 100 || response.statusCode > 299)) {
|
||||
if (retriesRemaining > 0) {
|
||||
logger.info(`Failed to download from ${urlString}, attempting again (${retriesRemaining - 1} retries remaining).`);
|
||||
delay(Math.pow(2, attempt) * 200).then(() => {
|
||||
downloadFile(url.toString(), filePath, retriesRemaining - 1, attempt + 1).then(resolve, reject);
|
||||
});
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Cannot download file "${urlString}", error code=${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
@ -88,7 +101,8 @@ function downloadFile(urlString, filePath) {
|
||||
function downloadTypingsToFile(typingsFilePath, version) {
|
||||
return downloadFile(
|
||||
`https://unpkg.com/lightweight-charts@${version}/dist/typings.d.ts`,
|
||||
typingsFilePath
|
||||
typingsFilePath,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -24,6 +24,10 @@ export function replaceThemeConstantStrings(originalString, isDarkTheme) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function removeUnwantedLines(originalString) {
|
||||
return originalString.replace(new RegExp(/\/\/ delete-start[\w\W]*?\/\/ delete-end/, 'gm'), '');
|
||||
}
|
||||
|
||||
const EnhancedCodeBlock = props => {
|
||||
const { chart, replaceThemeConstants, hideableCode, ...rest } = props;
|
||||
let { children } = props;
|
||||
@ -34,6 +38,7 @@ const EnhancedCodeBlock = props => {
|
||||
if (replaceThemeConstants && typeof children === 'string') {
|
||||
children = replaceThemeConstantStrings(children, isDarkTheme);
|
||||
}
|
||||
children = removeUnwantedLines(children);
|
||||
|
||||
if (chart || hideableCode) {
|
||||
return (
|
||||
|
@ -1,3 +1,9 @@
|
||||
// delete-start
|
||||
/* Note: this file shouldn't be used directly because it has some constants which are set by
|
||||
the docusaurus site to ensure that the chart looks great in both dark and light color themes.
|
||||
If you want to use this example then please copy the code presented on the documentation site.
|
||||
[link](https://tradingview.github.io/lightweight-charts/tutorials/react/advanced) */
|
||||
// delete-end
|
||||
import { createChart } from 'lightweight-charts';
|
||||
import React, {
|
||||
createContext,
|
||||
@ -29,8 +35,9 @@ export const App = props => {
|
||||
backgroundColor = CHART_BACKGROUND_COLOR,
|
||||
lineColor = LINE_LINE_COLOR,
|
||||
textColor = CHART_TEXT_COLOR,
|
||||
},
|
||||
} = {},
|
||||
} = props;
|
||||
|
||||
const [chartLayoutOptions, setChartLayoutOptions] = useState({});
|
||||
// The following variables illustrate how a series could be updated.
|
||||
const series1 = useRef(null);
|
||||
|
@ -1,3 +1,9 @@
|
||||
// delete-start
|
||||
/* Note: this file shouldn't be used directly because it has some constants which are set by
|
||||
the docusaurus site to ensure that the chart looks great in both dark and light color themes.
|
||||
If you want to use this example then please copy the code presented on the documentation site.
|
||||
[link](https://tradingview.github.io/lightweight-charts/tutorials/react/simple) */
|
||||
// delete-end
|
||||
import { createChart, ColorType } from 'lightweight-charts';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
@ -10,8 +16,9 @@ export const ChartComponent = props => {
|
||||
textColor = CHART_TEXT_COLOR,
|
||||
areaTopColor = AREA_TOP_COLOR,
|
||||
areaBottomColor = AREA_BOTTOM_COLOR,
|
||||
},
|
||||
} = {},
|
||||
} = props;
|
||||
|
||||
const chartContainerRef = useRef();
|
||||
|
||||
useEffect(
|
||||
|
Loading…
Reference in New Issue
Block a user