refactor: prepare vue components for vapor mode (#38798)

Removes what would block a later switch to Vue's vapor mode:
`vue-chartjs` and the `SvgIcon` render function are virtual DOM
components, and `v-memo` has no vapor equivalent. This does not adopt
vapor mode, which will be stable in upcoming Vue 3.6.

`vue-chartjs` was a thin wrapper over chart.js, so a local
`ChartCanvas.vue` replaces it. Chart data and options move into computed
values to keep their object identity, which is what `v-memo` was
compensating for.

`chartjs-adapter-dayjs-4` is moved first-party, just ~40 lines that are
easy to maintain.
This commit is contained in:
silverwind
2026-08-07 14:38:31 +02:00
committed by GitHub
parent d86cb1a498
commit e81ab0a5ea
22 changed files with 178 additions and 145 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import ActionStatusIcon from './ActionStatusIcon.vue';
import {addDelegatedEventListener, createElementFromAttrs} from '../utils/dom.ts';
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
+1 -1
View File
@@ -3,7 +3,7 @@
-->
<script lang="ts" setup>
import {computed} from 'vue';
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import {getActionStatusIcon, type ActionStatusIconVariant} from '../modules/action-status-icon.ts';
const props = withDefaults(defineProps<{
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts" setup>
import {onMounted, onUnmounted, toRaw, useTemplateRef, watch, type ShallowRef} from 'vue';
import {_adapters, BarController, Chart, LinearScale, LineController, TimeScale, type ChartData, type ChartOptions, type TimeUnit} from 'chart.js';
import {chartJsColors} from '../utils/color.ts';
import dayjs from 'dayjs';
import advancedFormat from 'dayjs/plugin/advancedFormat.js';
import quarterOfYear from 'dayjs/plugin/quarterOfYear.js';
import type {ConfigType, ManipulateType} from 'dayjs';
dayjs.extend(advancedFormat); // the quarter format needs the `Q` token
dayjs.extend(quarterOfYear);
Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
Chart.register(BarController, LineController, LinearScale, TimeScale);
// minimal port of chartjs-adapter-dayjs-4, MIT license, Copyright (c) 2022 bolstycjw
_adapters._date.override({
formats: () => ({
datetime: 'MMM D, YYYY, h:mm:ss a',
millisecond: 'h:mm:ss.SSS a',
second: 'h:mm:ss a',
minute: 'h:mm a',
hour: 'hA',
day: 'MMM D',
week: 'MMM D, YYYY',
month: 'MMM YYYY',
quarter: '[Q]Q - YYYY',
year: 'YYYY',
}),
parse: (value: ConfigType) => {
const date = dayjs(value);
return date.isValid() ? date.valueOf() : null;
},
format: (time: number, format: string) => dayjs(time).format(format),
add: (time: number, amount: number, unit: TimeUnit) => dayjs(time).add(amount, unit as ManipulateType).valueOf(), // the quarter plugin widens this at runtime
diff: (max: number, min: number, unit: TimeUnit) => dayjs(max).diff(min, unit),
// chart.js only asks for `isoWeek` when `time.isoWeekday` is set, which we never do
startOf: (time: number, unit: TimeUnit) => dayjs(time).startOf(unit).valueOf(),
endOf: (time: number, unit: TimeUnit) => dayjs(time).endOf(unit).valueOf(),
});
const props = defineProps<{
type: 'bar' | 'line',
data: ChartData,
options: ChartOptions,
}>();
const elCanvas = useTemplateRef('elCanvas') as Readonly<ShallowRef<HTMLCanvasElement>>;
let chart: Chart | undefined;
// chart.js mutates what it gets, so it must never see a reactive proxy
onMounted(() => {
chart = new Chart(elCanvas.value, {type: props.type, data: toRaw(props.data), options: toRaw(props.options)});
});
onUnmounted(() => {
chart?.destroy();
});
watch([() => props.data, () => props.options], ([data, options]) => {
if (!chart) return; // chart creation failed
chart.data = toRaw(data);
chart.options = toRaw(options);
chart.update();
});
</script>
<template>
<canvas ref="elCanvas" role="img"/>
</template>
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import {getIssueColorClass, getIssueIcon} from '../features/issue.ts';
import {computed} from 'vue';
import type {Issue} from '../types.ts';
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import {computed, nextTick, onMounted, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import {GET} from '../modules/fetch.ts';
import {urlQueryEscape} from '../utils/url.ts';
import type {SvgName} from '../svg.ts';
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import {computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import {GET} from '../modules/fetch.ts';
import {generateElemId} from '../utils/dom.ts';
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import {SvgIcon, type SvgName} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import type {SvgName} from '../svg.ts';
import {shallowRef} from 'vue';
import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts';
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import {toggleElem} from '../utils/dom.ts';
const props = defineProps<{
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import ActionStatusIcon from './ActionStatusIcon.vue';
import {computed, onBeforeUnmount, ref, toRefs, watch} from 'vue';
import {resetActionFavicon, syncActionRunFavicon} from '../modules/favicon-status.ts';
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import {computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch, type ShallowRef} from 'vue';
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import {showErrorToast} from '../modules/toast.ts';
import {GET} from '../modules/fetch.ts';
import {pathEscapeSegments} from '../utils/url.ts';
+9 -15
View File
@@ -1,10 +1,8 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import {
Chart,
Legend,
LinearScale,
TimeScale,
PointElement,
LineElement,
Filler,
@@ -12,7 +10,7 @@ import {
type ChartData,
} from 'chart.js';
import {GET} from '../modules/fetch.ts';
import {Line as ChartLine} from 'vue-chartjs';
import ChartCanvas from './ChartCanvas.vue';
import {
startDaysBetween,
firstStartDateAfterDate,
@@ -23,17 +21,11 @@ import {
import {chartJsColors} from '../utils/color.ts';
import {errorMessage} from '../modules/errors.ts';
import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import {onMounted, shallowRef} from 'vue';
import {computed, onMounted, shallowRef} from 'vue';
const {pageData} = window.config;
Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
Chart.register(
TimeScale,
LinearScale,
Legend,
PointElement,
LineElement,
@@ -85,7 +77,9 @@ async function fetchGraphData() {
}
}
function toGraphData(data: Array<Record<string, any>>): ChartData<'line'> {
const graphData = computed(() => toGraphData(data.value));
function toGraphData(data: DayData[]): ChartData<'line'> {
return {
datasets: [
{
@@ -159,9 +153,9 @@ const options: ChartOptions<'line'> = {
{{ errorText }}
</div>
</div>
<ChartLine
v-memo="data" v-if="data.length !== 0"
:data="toGraphData(data)" :options="options"
<ChartCanvas
v-if="data.length !== 0"
type="line" :data="graphData" :options="options"
/>
</div>
</div>
+26 -23
View File
@@ -1,15 +1,12 @@
<script lang="ts" setup>
import {computed, onMounted, shallowRef} from 'vue';
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import dayjs from 'dayjs';
import {GET} from '../modules/fetch.ts';
import {Line as ChartLine} from 'vue-chartjs';
import ChartCanvas from './ChartCanvas.vue';
import {
Chart,
Title,
BarElement,
LinearScale,
TimeScale,
PointElement,
LineElement,
Filler,
@@ -19,7 +16,6 @@ import {
} from 'chart.js';
import zoomPlugin from 'chartjs-plugin-zoom';
import {chartJsColors} from '../utils/color.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import {
startDaysBetween,
firstStartDateAfterDate,
@@ -56,13 +52,7 @@ type LineOptions = ChartOptions<'line'> & {
};
}
Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
Chart.register(
TimeScale,
LinearScale,
BarElement,
Title,
PointElement,
LineElement,
@@ -102,7 +92,8 @@ const errorText = shallowRef('');
const totalStats = shallowRef<Record<string, any>>({});
const sortedContributors = shallowRef<Array<Record<string, any>>>([]);
const type = shallowRef<ContributionType>('commits');
let contributorsStats: Record<string, any> = {}; // these three are not read during render
let contributorsStats: Record<string, any> = {};
// plain values, so the main chart options do not follow the zoomed range
let xAxisStart: number | null = null;
let xAxisEnd: number | null = null;
const xAxisMin = shallowRef<number | null>(null);
@@ -301,8 +292,9 @@ function getOptions(chartType: ChartType): LineOptions {
},
scales: {
x: {
min: xAxisMin.value ?? undefined,
max: xAxisMax.value ?? undefined,
// the main chart keeps its own zoom range
min: (chartType === 'main' ? xAxisStart : xAxisMin.value) ?? undefined,
max: (chartType === 'main' ? xAxisEnd : xAxisMax.value) ?? undefined,
type: 'time',
grid: {
display: false,
@@ -325,6 +317,17 @@ function getOptions(chartType: ChartType): LineOptions {
},
};
}
const mainChart = computed(() => ({
graphData: toGraphData(totalStats.value.weeks),
chartOptions: getOptions('main'),
}));
const contributorCharts = computed(() => sortedContributors.value.map((contributor) => ({
contributor,
graphData: toGraphData(contributor.weeks),
chartOptions: getOptions('contributor'), // chart.js mutates it, so each chart needs its own
})));
</script>
<template>
<div>
@@ -386,16 +389,15 @@ function getOptions(chartType: ChartType): LineOptions {
{{ errorText }}
</div>
</div>
<ChartLine
v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0"
:data="toGraphData(totalStats.weeks)" :options="getOptions('main')"
<ChartCanvas
v-if="Object.keys(totalStats).length !== 0"
type="line" :data="mainChart.graphData" :options="mainChart.chartOptions"
/>
</div>
<div class="contributor-grid">
<div
v-for="(contributor, index) in sortedContributors"
v-for="({contributor, graphData, chartOptions}, index) in contributorCharts"
:key="index"
v-memo="[sortedContributors, type]"
>
<div class="ui top attached header tw-flex tw-flex-1">
<b class="ui right">#{{ index + 1 }}</b>
@@ -421,9 +423,10 @@ function getOptions(chartType: ChartType): LineOptions {
</div>
<div class="ui attached segment">
<div>
<ChartLine
:data="toGraphData(contributor.weeks)"
:options="getOptions('contributor')"
<ChartCanvas
type="line"
:data="graphData"
:options="chartOptions"
/>
</div>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ import {generateElemId} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
import {filterRepoFilesWeighted} from '../features/repo-findfile.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import {throttle} from '../utils/func.ts';
const props = defineProps({
+9 -15
View File
@@ -1,17 +1,15 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import {
Chart,
Tooltip,
BarElement,
LinearScale,
TimeScale,
type ChartOptions,
type ChartData,
type ChartDataset,
} from 'chart.js';
import {GET} from '../modules/fetch.ts';
import {Bar} from 'vue-chartjs';
import ChartCanvas from './ChartCanvas.vue';
import {
startDaysBetween,
firstStartDateAfterDate,
@@ -22,17 +20,11 @@ import {
import {chartJsColors} from '../utils/color.ts';
import {errorMessage} from '../modules/errors.ts';
import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import {onMounted, ref, shallowRef} from 'vue';
import {computed, onMounted, shallowRef} from 'vue';
const {pageData} = window.config;
Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
Chart.register(
TimeScale,
LinearScale,
BarElement,
Tooltip,
);
@@ -48,7 +40,7 @@ defineProps<{
const isLoading = shallowRef(false);
const errorText = shallowRef('');
const repoLink = pageData.repoLink!;
const data = ref<DayData[]>([]);
const data = shallowRef<DayData[]>([]);
onMounted(() => {
fetchGraphData();
@@ -81,6 +73,8 @@ async function fetchGraphData() {
}
}
const graphData = computed(() => toGraphData(data.value));
function toGraphData(data: DayData[]): ChartData<'bar'> {
return {
datasets: [
@@ -137,9 +131,9 @@ const options: ChartOptions<'bar'> = {
{{ errorText }}
</div>
</div>
<Bar
v-memo="data" v-if="data.length !== 0"
:data="toGraphData(data)" :options="options"
<ChartCanvas
v-if="data.length !== 0"
type="bar" :data="graphData" :options="options"
/>
</div>
</div>
+10
View File
@@ -0,0 +1,10 @@
import SvgIcon from './SvgIcon.vue';
import {createApp, h} from 'vue';
test('SvgIcon', () => {
const root = document.createElement('div');
createApp({render: () => h(SvgIcon, {name: 'octicon-dot-fill', size: 24, class: 'base', symbolId: 'svg-symbol-dot'})}).mount(root);
expect(root.innerHTML).toBe(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="24" height="24" aria-hidden="true" class="svg octicon-dot-fill tw-hidden svg-symbol-container base"><symbol id="svg-symbol-dot" viewBox="0 0 16 16"><path d="M8 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8"></path></symbol></svg>`,
);
});
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts" setup>
import {computed} from 'vue';
import {svgParseOuterInner, type SvgName} from '../svg.ts';
import {html, htmlRaw} from '../utils/html.ts';
const props = withDefaults(defineProps<{
name: SvgName,
size?: number,
symbolId?: string,
}>(), {
size: 16,
symbolId: undefined,
});
const icon = computed(() => {
let {svgOuter, svgInnerHtml} = svgParseOuterInner(props.name);
const attrs: Record<string, string | number> = {};
for (const attr of svgOuter.attributes) {
if (attr.name === 'class') continue;
attrs[attr.name] = attr.value;
}
attrs.width = props.size;
attrs.height = props.size;
const classes = Array.from(svgOuter.classList);
if (props.symbolId) {
classes.push('tw-hidden', 'svg-symbol-container');
svgInnerHtml = html`<symbol id="${props.symbolId}" viewBox="${attrs.viewBox}">${htmlRaw(svgInnerHtml)}</symbol>`;
}
attrs.innerHTML = svgInnerHtml; // the icons are bundled, they carry no user input
return {attrs, classes};
});
</script>
<template>
<svg v-bind="icon.attrs" :class="icon.classes"/>
</template>
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import {isPlainClick} from '../utils/dom.ts';
import {shouldTriggerAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {shallowRef} from 'vue';
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import SvgIcon from './SvgIcon.vue';
import ActionStatusIcon from './ActionStatusIcon.vue';
import {localUserSettings} from '../modules/user-settings.ts';
import {isPlainClick} from '../utils/dom.ts';