From a26fe19fe608e2dd348717857cae5cde845df05f Mon Sep 17 00:00:00 2001 From: Dmitri Pisarev Date: Wed, 5 Aug 2026 09:16:20 +0300 Subject: [PATCH 1/2] Implement measured frontend performance improvements --- .dockerignore | 12 + app/Routes.tsx | 44 +- app/client.tsx | 2 + app/components/Button/Button.tsx | 45 +- app/components/HeightUpdate/HeightUpdater.tsx | 20 +- app/components/InPageSearch/InPageSearch.tsx | 194 +-- app/components/LayoutInner/LayoutInner.tsx | 42 +- .../LayoutInner/LayoutOverflowMenuItems.tsx | 15 + app/components/RteText/RteText.tsx | 10 +- .../ScriptEditor/ActiveScriptEditorInput.tsx | 107 ++ .../ScriptEditor/ScriptEditorInput.tsx | 125 +- app/components/SermonList/SermonList.tsx | 15 +- app/components/TOC/TOCProvider.tsx | 82 ++ app/components/TOC/tocRegistry.test.mjs | 148 ++ app/components/TOC/tocRegistry.ts | 149 ++ app/components/Tooltip/Tooltip.tsx | 5 + app/components/Typography/Typography.tsx | 58 +- .../Typography/headingLabel.test.mjs | 29 + app/components/Typography/headingLabel.ts | 27 + .../trackedPrayerTimeOfDay.test.mjs | 23 + .../HabitTracker/trackedPrayerTimeOfDay.ts | 9 + app/containers/HabitTracker/usePrayerTimer.ts | 6 +- app/containers/Main/Services.tsx | 22 +- app/containers/Main/SettingsMenu.tsx | 18 +- app/containers/Service/LangContext.tsx | 12 +- app/containers/Service/MDXProvider.tsx | 30 +- app/containers/Service/Service.tsx | 188 ++- app/containers/Service/ServiceContext.tsx | 6 +- app/containers/Service/TOCSwitcher.tsx | 84 +- app/containers/Service/Texts/MdxLoader.tsx | 106 +- .../Service/Texts/MdxLoaderRuntime.tsx | 46 + .../Texts/suspenseResourceCache.test.mjs | 98 ++ .../Service/Texts/suspenseResourceCache.ts | 71 + .../Service/runtimeOptimizations.test.mjs | 62 + app/data/calendarQueryPolicy.test.mjs | 32 + app/data/calendarQueryPolicy.ts | 50 + app/data/readingQueryPolicy.test.mjs | 38 + app/data/readingQueryPolicy.ts | 28 + app/hooks/useAudio.ts | 79 +- app/hooks/useDay.ts | 2 + app/hooks/useExternalDay.ts | 2 + app/hooks/useParts.ts | 2 + app/hooks/useReading.ts | 11 +- app/hooks/useReadings.ts | 5 +- app/hooks/useUpdateTOC.ts | 54 - app/routeLoaders.ts | 6 + app/state/TOCState.ts | 23 - app/utils/performanceTelemetry.ts | 123 ++ app/utils/staticDelivery.mjs | 32 + app/utils/staticDelivery.test.mjs | 85 ++ docs/modernization/02-performance.md | 81 +- .../04-code-quality-and-architecture.md | 26 +- ...-older-device-and-perceived-performance.md | 685 +++++++++ docs/modernization/README.md | 150 +- .../modernization/frontend-state-ownership.md | 76 +- ...ce-performance-implementation-summary.json | 76 + .../service-performance-report.json | 216 +++ .../service-performance-study.md | 515 +++++++ package.json | 6 +- scripts/deploy-static.mjs | 202 +++ scripts/performance/benchmark-service.mjs | 1272 +++++++++++++++++ server.js | 17 +- tests/e2e/app.spec.ts | 192 ++- webpack.prod.js | 8 +- yarn.lock | 37 +- 65 files changed, 5328 insertions(+), 713 deletions(-) create mode 100644 app/components/LayoutInner/LayoutOverflowMenuItems.tsx create mode 100644 app/components/ScriptEditor/ActiveScriptEditorInput.tsx create mode 100644 app/components/TOC/TOCProvider.tsx create mode 100644 app/components/TOC/tocRegistry.test.mjs create mode 100644 app/components/TOC/tocRegistry.ts create mode 100644 app/components/Typography/headingLabel.test.mjs create mode 100644 app/components/Typography/headingLabel.ts create mode 100644 app/containers/HabitTracker/trackedPrayerTimeOfDay.test.mjs create mode 100644 app/containers/Service/Texts/MdxLoaderRuntime.tsx create mode 100644 app/containers/Service/Texts/suspenseResourceCache.test.mjs create mode 100644 app/containers/Service/Texts/suspenseResourceCache.ts create mode 100644 app/containers/Service/runtimeOptimizations.test.mjs create mode 100644 app/data/calendarQueryPolicy.test.mjs create mode 100644 app/data/calendarQueryPolicy.ts create mode 100644 app/data/readingQueryPolicy.test.mjs create mode 100644 app/data/readingQueryPolicy.ts delete mode 100644 app/hooks/useUpdateTOC.ts create mode 100644 app/routeLoaders.ts delete mode 100644 app/state/TOCState.ts create mode 100644 app/utils/performanceTelemetry.ts create mode 100644 app/utils/staticDelivery.mjs create mode 100644 app/utils/staticDelivery.test.mjs create mode 100644 docs/modernization/06-older-device-and-perceived-performance.md create mode 100644 docs/modernization/service-performance-implementation-summary.json create mode 100644 docs/modernization/service-performance-report.json create mode 100644 docs/modernization/service-performance-study.md create mode 100644 scripts/deploy-static.mjs create mode 100644 scripts/performance/benchmark-service.mjs diff --git a/.dockerignore b/.dockerignore index 801235cf94..11044958d9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,15 @@ convertBibleQuote/bible /electron /android /.circleci +/node_modules +/.git +/.env +/.env.* +/.auto-claude +/ZITADEL_AUTH_CONFIG.md +/www/built +/www/index.html +/www/service-worker.* +/www/version +/test-results +/playwright-report diff --git a/app/Routes.tsx b/app/Routes.tsx index e474c28623..ea97c08169 100644 --- a/app/Routes.tsx +++ b/app/Routes.tsx @@ -1,19 +1,18 @@ import React, { Suspense, useEffect, useRef } from 'react'; import { Navigate, Route, Routes as RouterRoutes, useLocation, useParams } from 'react-router-dom'; -import Main from 'containers/Main/Main'; -import NotFound from 'components/NotFound/NotFound'; -import Readings from 'containers/Readings/Readings'; import dateFormat from 'dateformat'; -import Sermon from 'containers/Sermon/Sermon'; -import Saint from 'containers/Saint/Saint'; -import ThisDay from 'containers/ThisDay/ThisDay'; -import Service from 'containers/Service/Service'; import { Global, ThemeProvider, css as rcss } from '@emotion/react'; import { css } from '@emotion/css'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; + +import { loadServiceRoute } from './routeLoaders'; +import checkVersion from './checkVersion'; + +import Main from 'containers/Main/Main'; +import NotFound from 'components/NotFound/NotFound'; import useDay from 'hooks/useDay'; import getTheme from 'styles/getTheme'; import langState from 'state/langState'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; import { LangContext } from 'containers/Service/LangContext'; import pendingUpdateState from 'state/pendingUpdateState'; import UpdatePrompt from 'components/UpdatePrompt/UpdatePrompt'; @@ -21,9 +20,7 @@ import isParallelState from 'state/isParallel'; import { Promo } from 'components/Promo/Promo'; import Loader from 'components/Loader/Loader'; import themeState from 'state/themeState'; -import SettingsMenu from 'containers/Main/SettingsMenu'; - -import checkVersion from './checkVersion'; +import menuShownState from 'state/menuShownState'; const Hymns = React.lazy(async () => { const module = await import(/* webpackChunkName: "route-hymns" */ 'containers/Hymns/Hymns'); @@ -33,6 +30,20 @@ const Hymn = React.lazy(async () => { const module = await import(/* webpackChunkName: "route-hymns" */ 'containers/Hymns/Hymn'); return { default: module.Hymn }; }); +const Readings = React.lazy( + async () => await import(/* webpackChunkName: "route-readings" */ 'containers/Readings/Readings') +); +const Sermon = React.lazy( + async () => await import(/* webpackChunkName: "route-date-sermon" */ 'containers/Sermon/Sermon') +); +const Saint = React.lazy(async () => await import(/* webpackChunkName: "route-date-saint" */ 'containers/Saint/Saint')); +const ThisDay = React.lazy( + async () => await import(/* webpackChunkName: "route-this-day" */ 'containers/ThisDay/ThisDay') +); +const Service = React.lazy(loadServiceRoute); +const SettingsMenu = React.lazy( + async () => await import(/* webpackChunkName: "settings-menu" */ 'containers/Main/SettingsMenu') +); const Profile = React.lazy( async () => await import(/* webpackChunkName: "route-profile" */ 'containers/Profile/Profile') ); @@ -110,6 +121,7 @@ const Routes = () => { }; }, [location.key, setPendingUpdate]); const isParallel = useRecoilValue(isParallelState); + const menuShown = useRecoilValue(menuShownState); const themeStateValue = useRecoilValue(themeState); const theme = getTheme(undefined, themeStateValue); @@ -138,14 +150,16 @@ const Routes = () => { margin: 0 auto; `} > - + {menuShown && ( + + + + )} }> - } + element={} /> } /> } /> diff --git a/app/client.tsx b/app/client.tsx index b35883f19f..c06ae0455d 100644 --- a/app/client.tsx +++ b/app/client.tsx @@ -14,6 +14,7 @@ import Worker from './precache.worker.js'; import './redirectToHome'; import { isCapacitor } from 'utils/deviceInfo'; import precache from 'precache.ts'; +import { startPerformanceTelemetry } from 'utils/performanceTelemetry'; window.APP_LOADED = true; const isProd = process.env.NODE_ENV === 'production'; @@ -36,6 +37,7 @@ if (isProd) { namesSubmit: 'Names Submit', }, }); + startPerformanceTelemetry(); } const rootElement = document.getElementById('react-root'); diff --git a/app/components/Button/Button.tsx b/app/components/Button/Button.tsx index f97b527579..cdf6b9969a 100644 --- a/app/components/Button/Button.tsx +++ b/app/components/Button/Button.tsx @@ -1,26 +1,29 @@ import React, { forwardRef } from 'react'; import { css } from '@emotion/css'; -const Button = forwardRef((props, ref) => ( - -)); +const Button = forwardRef>( + ({ children, className = '', title, ...props }, ref) => ( + + ) +); export default Button; diff --git a/app/components/HeightUpdate/HeightUpdater.tsx b/app/components/HeightUpdate/HeightUpdater.tsx index da361bd32e..7393b539c6 100644 --- a/app/components/HeightUpdate/HeightUpdater.tsx +++ b/app/components/HeightUpdate/HeightUpdater.tsx @@ -1,12 +1,26 @@ import React, { useContext, useEffect } from 'react'; import { SwipeableViewsContext } from 'react-swipeable-views'; +interface SwipeableViewsContextValue { + slideUpdateHeight?: () => void; +} + export const HeightUpdater = ({ children }: { children: React.ReactNode }): JSX.Element => { - const context = useContext(SwipeableViewsContext); + const context = useContext( + SwipeableViewsContext as unknown as React.Context + ); useEffect(() => { - setTimeout(() => { - context?.slideUpdateHeight?.(); + const updateHeight = context?.slideUpdateHeight; + if (!updateHeight) { + return undefined; + } + + const timer = window.setTimeout(() => { + updateHeight(); }, 30); + return () => { + window.clearTimeout(timer); + }; }); return <>{children}; }; diff --git a/app/components/InPageSearch/InPageSearch.tsx b/app/components/InPageSearch/InPageSearch.tsx index 75b05bfd54..9c354347d3 100644 --- a/app/components/InPageSearch/InPageSearch.tsx +++ b/app/components/InPageSearch/InPageSearch.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { css } from '@emotion/css'; import { useTheme } from '@emotion/react'; @@ -7,103 +7,99 @@ import Button from 'components/Button/Button'; const HIGHLIGHT_CLASS = 'inpage-find-highlight'; const ACTIVE_CLASS = 'inpage-find-active'; +const SEARCH_DEBOUNCE_MS = 180; +const MINIMUM_QUERY_LENGTH = 2; const useHighlights = (containerRef: React.RefObject) => { - const clearHighlights = () => { + const clearHighlights = useCallback(() => { const container = containerRef.current; - if (!container) return; + if (!container) { + return; + } const highlighted = Array.from(container.querySelectorAll(`span.${HIGHLIGHT_CLASS}`)); highlighted.forEach((span) => { const parent = span.parentNode; - if (!parent) return; - while (span.firstChild) parent.insertBefore(span.firstChild, span); + if (!parent) { + return; + } + while (span.firstChild) { + parent.insertBefore(span.firstChild, span); + } parent.removeChild(span); parent.normalize(); }); - }; - - const createHighlights = (query: string) => { - const container = containerRef.current; - if (!container) return [] as HTMLElement[]; - const matches: HTMLElement[] = []; - const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { - acceptNode: (node: any) => { - if (!node || !node.data) { - return NodeFilter.FILTER_REJECT; - } - // Skip text nodes that are inside script/style or already highlighted - const parentElement = node.parentElement || null; - if (!parentElement) { - return NodeFilter.FILTER_REJECT; - } - if (parentElement.closest('script, style')) { - return NodeFilter.FILTER_REJECT; - } - if (parentElement.classList?.contains(HIGHLIGHT_CLASS)) { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - }, - } as any); - - const q = query; - if (!q) { - return matches; - } - const lowerQ = q.toLocaleLowerCase(); - - const toProcess: Text[] = []; - let current: Node | null = walker.nextNode(); - while (current) { - toProcess.push(current as Text); - current = walker.nextNode(); - } + }, [containerRef]); - toProcess.forEach((textNode) => { - const text = textNode.data; - const textLower = text.toLocaleLowerCase(); - let startIndex = 0; - let containerFragment: DocumentFragment | null = null; - let hasMatch = false; - - // Collect all match ranges first - const ranges: Array<{ start: number; end: number }> = []; - let idx = textLower.indexOf(lowerQ, startIndex); - while (idx !== -1) { - ranges.push({ start: idx, end: idx + lowerQ.length }); - startIndex = idx + lowerQ.length; - idx = textLower.indexOf(lowerQ, startIndex); + const createHighlights = useCallback( + (query: string) => { + const container = containerRef.current; + if (!container) { + return []; } - - if (!ranges.length) { - return; + const matches: HTMLElement[] = []; + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, { + acceptNode: (node: Node) => { + if (!node.textContent) { + return NodeFilter.FILTER_REJECT; + } + const parentElement = node.parentElement; + if ( + !parentElement || + parentElement.closest('script, style') || + parentElement.classList.contains(HIGHLIGHT_CLASS) + ) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + }, + }); + const lowerQuery = query.toLocaleLowerCase(); + const toProcess: Text[] = []; + let current = walker.nextNode(); + while (current) { + toProcess.push(current as Text); + current = walker.nextNode(); } - containerFragment = document.createDocumentFragment(); - let lastIndex = 0; - ranges.forEach((r) => { - if (r.start > lastIndex) { - containerFragment!.appendChild(document.createTextNode(text.slice(lastIndex, r.start))); + toProcess.forEach((textNode) => { + const text = textNode.data; + const textLower = text.toLocaleLowerCase(); + let startIndex = 0; + const ranges: Array<{ start: number; end: number }> = []; + let matchIndex = textLower.indexOf(lowerQuery, startIndex); + while (matchIndex !== -1) { + ranges.push({ start: matchIndex, end: matchIndex + lowerQuery.length }); + startIndex = matchIndex + lowerQuery.length; + matchIndex = textLower.indexOf(lowerQuery, startIndex); + } + + if (!ranges.length) { + return; } - const span = document.createElement('span'); - span.className = HIGHLIGHT_CLASS; - span.textContent = text.slice(r.start, r.end); - containerFragment!.appendChild(span); - matches.push(span); - lastIndex = r.end; - hasMatch = true; - }); - if (lastIndex < text.length) { - containerFragment.appendChild(document.createTextNode(text.slice(lastIndex))); - } - if (hasMatch && containerFragment) { + const containerFragment = document.createDocumentFragment(); + let lastIndex = 0; + ranges.forEach((range) => { + if (range.start > lastIndex) { + containerFragment.appendChild(document.createTextNode(text.slice(lastIndex, range.start))); + } + const span = document.createElement('span'); + span.className = HIGHLIGHT_CLASS; + span.textContent = text.slice(range.start, range.end); + containerFragment.appendChild(span); + matches.push(span); + lastIndex = range.end; + }); + if (lastIndex < text.length) { + containerFragment.appendChild(document.createTextNode(text.slice(lastIndex))); + } textNode.replaceWith(containerFragment); - } - }); + }); - return matches; - }; + return matches; + }, + [containerRef] + ); return { clearHighlights, createHighlights }; }; @@ -116,10 +112,15 @@ const InPageSearch = ({ containerRef, onClose }: InPageSearchProps) => { const theme = useTheme(); const inputRef = useRef(null); const [query, setQuery] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); const [matches, setMatches] = useState([]); const [activeIndex, setActiveIndex] = useState(0); const { clearHighlights, createHighlights } = useHighlights(containerRef); + const handleClose = useCallback(() => { + clearHighlights(); + onClose(); + }, [clearHighlights, onClose]); // Focus input on mount useEffect(() => { @@ -133,22 +134,32 @@ const InPageSearch = ({ containerRef, onClose }: InPageSearchProps) => { document.addEventListener('keydown', onKey, true); return () => { document.removeEventListener('keydown', onKey, true); + clearHighlights(); }; - }, []); + }, [clearHighlights, handleClose]); + + useEffect(() => { + if (query.length < MINIMUM_QUERY_LENGTH) { + setSearchQuery(''); + return undefined; + } + + const timeoutId = window.setTimeout(() => setSearchQuery(query), SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timeoutId); + }, [query]); - // Update highlights when query changes + // Updating the long service DOM is deliberately delayed until typing pauses. useEffect(() => { clearHighlights(); - if (!query || query.length === 0) { + if (!searchQuery) { setMatches([]); setActiveIndex(0); return; } - const newMatches = createHighlights(query); + const newMatches = createHighlights(searchQuery); setMatches(newMatches); - setActiveIndex(newMatches.length ? 0 : 0); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query]); + setActiveIndex(0); + }, [clearHighlights, createHighlights, searchQuery]); // Ensure only active match has active class and is scrolled into view useEffect(() => { @@ -164,11 +175,6 @@ const InPageSearch = ({ containerRef, onClose }: InPageSearchProps) => { }); }, [activeIndex, matches]); - const handleClose = () => { - clearHighlights(); - onClose(); - }; - const gotoNext = () => { if (!matches.length) { return; @@ -220,7 +226,7 @@ const InPageSearch = ({ containerRef, onClose }: InPageSearchProps) => { value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDownInput} - placeholder="Найти на странице" + placeholder="Найти (от 2 букв)" className={css` border: 1px solid ${theme.colours.lineGray}; border-radius: 6px; diff --git a/app/components/LayoutInner/LayoutInner.tsx b/app/components/LayoutInner/LayoutInner.tsx index 90ee1fff7d..f8e426db92 100644 --- a/app/components/LayoutInner/LayoutInner.tsx +++ b/app/components/LayoutInner/LayoutInner.tsx @@ -1,18 +1,20 @@ -import React, { useRef, useState } from 'react'; +import React, { Suspense, useCallback, useRef, useState } from 'react'; import { Link, useLocation, useParams } from 'react-router-dom'; -import LeftIcon from 'components/svgs/LeftIcon'; import { css } from '@emotion/css'; + +import LeftIcon from 'components/svgs/LeftIcon'; import Header from 'components/Header/Header'; import DotsMenu from 'components/DotsMenu/DotsMenu'; -import { useUpdateTOC } from 'hooks/useUpdateTOC'; -import Share from 'components/Share/Share'; import useDay from 'hooks/useDay'; import Button from 'components/Button/Button'; -import { useRecoilState } from 'recoil'; -import menuShownState from 'state/menuShownState'; -import SettingsButton from 'components/SettingsButton/SettingsButton'; -import { FindInPageButton } from 'components/FindInPageButton/FindInPageButton'; -import InPageSearch from 'components/InPageSearch/InPageSearch'; + +const LayoutOverflowMenuItems = React.lazy( + async () => + await import(/* webpackChunkName: "layout-overflow-menu" */ 'components/LayoutInner/LayoutOverflowMenuItems') +); +const InPageSearch = React.lazy( + async () => await import(/* webpackChunkName: "in-page-search" */ 'components/InPageSearch/InPageSearch') +); const LayoutInner = ({ children, @@ -35,11 +37,8 @@ const LayoutInner = ({ const dayQuery = useDay(date); const day = dayQuery.data; const location = useLocation(); - useUpdateTOC(); const backLinkEffective = backLink || location.state?.backLink || backLinkFallback; - const [menuShown, setMenuShown] = useRecoilState(menuShownState); - const backElement = (
(null); const [isFindOpen, setIsFindOpen] = useState(false); + const openFind = useCallback(() => setIsFindOpen(true), []); + const closeFind = useCallback(() => setIsFindOpen(false), []); return (
{right} - - setIsFindOpen(true)} /> - - + + +
@@ -111,7 +107,11 @@ const LayoutInner = ({ > {children}
- {isFindOpen && setIsFindOpen(false)} />} + {isFindOpen && ( + + + + )} ); }; diff --git a/app/components/LayoutInner/LayoutOverflowMenuItems.tsx b/app/components/LayoutInner/LayoutOverflowMenuItems.tsx new file mode 100644 index 0000000000..6fce52c15c --- /dev/null +++ b/app/components/LayoutInner/LayoutOverflowMenuItems.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import SettingsButton from 'components/SettingsButton/SettingsButton'; +import { FindInPageButton } from 'components/FindInPageButton/FindInPageButton'; +import Share from 'components/Share/Share'; + +const LayoutOverflowMenuItems = ({ dayTitle, onFindOpen }: { dayTitle: string; onFindOpen: () => void }) => ( + <> + + + + +); + +export default LayoutOverflowMenuItems; diff --git a/app/components/RteText/RteText.tsx b/app/components/RteText/RteText.tsx index ac1aaa8c73..a6aeca3bd1 100644 --- a/app/components/RteText/RteText.tsx +++ b/app/components/RteText/RteText.tsx @@ -1,6 +1,7 @@ -import React, { useRef } from 'react'; +import React, { useImperativeHandle, useRef } from 'react'; import { css } from '@emotion/css'; import { useTheme } from '@emotion/react'; + import useAudio from 'hooks/useAudio'; interface RteTextProps { @@ -10,16 +11,17 @@ interface RteTextProps { const RteText = React.forwardRef(({ html = '', className = '' }, ref) => { const localRef = useRef(null); - const effectiveRef = ref || localRef; const theme = useTheme(); const htmlWithStrongSlashes = html .replace(/([\s])\/\/([\s])/g, '$1//$2') .replace(/([\s])\/([\s])/g, '$1/$2'); - useAudio(effectiveRef); + useImperativeHandle(ref, () => localRef.current as HTMLDivElement); + useAudio(localRef); return (
{ + const theme = useTheme(); + const [inputReaderName, setInputReaderName] = useState(null); + const storageKey = `ScriptEditor.${id}`; + const storedReaderName = window.localStorage.getItem(storageKey); + + const changeInputHandler = (event: React.ChangeEvent) => { + setInputReaderName(event.target.value); + }; + const saveNameHandler = () => { + window.localStorage.setItem(storageKey, inputReaderName || ''); + setInputReaderName(null); + }; + const toggleEditorHandler = () => { + setInputReaderName(storedReaderName || ''); + }; + const clearInputHandler = () => { + window.localStorage.setItem(storageKey, ''); + setInputReaderName(''); + }; + + return ( +
+
+
+ +
+ {inputReaderName !== null && ( + + + + )} +
+
+ ); +}; + +export default ActiveScriptEditorInput; diff --git a/app/components/ScriptEditor/ScriptEditorInput.tsx b/app/components/ScriptEditor/ScriptEditorInput.tsx index 1f58d01277..a1761d96fc 100644 --- a/app/components/ScriptEditor/ScriptEditorInput.tsx +++ b/app/components/ScriptEditor/ScriptEditorInput.tsx @@ -1,106 +1,37 @@ -import React, { useState } from 'react'; -import { css } from '@emotion/css'; -import { useTheme } from '@emotion/react'; -import Cross from 'components/svgs/Cross'; -import Button from 'components/Button/Button'; -import Pencil from 'components/svgs/Pencil'; -import Drawer from 'components/Drawer/Drawer'; -import Input from 'components/Input/Input'; -import scriptEditorIsActiveState from 'state/scriptEditorIsActiveState'; +import React, { Suspense, createContext, useContext } from 'react'; import { useRecoilValue } from 'recoil'; -const ScriptEditorInput = ({ id }) => { - const theme = useTheme(); - const [inputReaderName, setInputReaderName] = useState(null); +import scriptEditorIsActiveState from 'state/scriptEditorIsActiveState'; + +const ActiveScriptEditorInput = React.lazy( + async () => + await import( + /* webpackChunkName: "script-editor-input" */ + './ActiveScriptEditorInput' + ) +); + +const ScriptEditorActiveContext = createContext(false); + +export const ScriptEditorStateProvider = ({ children }: { children: React.ReactNode }): JSX.Element => { const scriptEditorIsActive = useRecoilValue(scriptEditorIsActiveState); - const storageKey = `ScriptEditor.${id}`; - const storedReaderName = window.localStorage.getItem(storageKey); + return ( + {children} + ); +}; + +const ScriptEditorInput = ({ id }: { id: string }): JSX.Element | null => { + const scriptEditorIsActive = useContext(ScriptEditorActiveContext); + + if (!scriptEditorIsActive) { + return null; + } - const changeInputHandler = (e) => { - setInputReaderName(e.target.value); - }; - const saveNameHandler = () => { - window.localStorage.setItem(storageKey, inputReaderName); - setInputReaderName(null); - }; - const toggleEditorHandler = () => { - setInputReaderName(storedReaderName || ''); - }; - const clearInputHandler = () => { - window.localStorage.setItem(storageKey, ''); - setInputReaderName(''); - }; return ( -
- {scriptEditorIsActive ? ( -
-
- -
- {inputReaderName !== null && ( - - - - )} -
- ) : null} -
+ + + ); }; diff --git a/app/components/SermonList/SermonList.tsx b/app/components/SermonList/SermonList.tsx index a649805a84..3398330f02 100644 --- a/app/components/SermonList/SermonList.tsx +++ b/app/components/SermonList/SermonList.tsx @@ -19,12 +19,11 @@ export const SermonList = ({ authorId, themeId, limit }: { authorId?: string; th const [hasMore, setHasMore] = useState(true); const [isLoadingMore, setIsLoadingMore] = useState(false); - const { data: sermons, status: sermonsStatus, refetch } = useFilteredSermons( - authorId, - themeId, - limit || SERMONS_PER_PAGE, - limit ? undefined : offset - ); + const { + data: sermons, + status: sermonsStatus, + refetch, + } = useFilteredSermons(authorId, themeId, limit || SERMONS_PER_PAGE, limit ? undefined : offset); const isLoading = sermonsStatus === 'pending' && offset === 0; const isError = sermonsStatus === 'error'; @@ -50,7 +49,7 @@ export const SermonList = ({ authorId, themeId, limit }: { authorId?: string; th } }, [sermons, offset, limit]); - const handleLoadMore = async () => { + const handleLoadMore = () => { setIsLoadingMore(true); setOffset((prev) => prev + SERMONS_PER_PAGE); }; @@ -58,7 +57,7 @@ export const SermonList = ({ authorId, themeId, limit }: { authorId?: string; th // Refetch when offset changes useEffect(() => { if (offset > 0) { - refetch(); + void refetch(); } }, [offset, refetch]); diff --git a/app/components/TOC/TOCProvider.tsx b/app/components/TOC/TOCProvider.tsx new file mode 100644 index 0000000000..af057d9854 --- /dev/null +++ b/app/components/TOC/TOCProvider.tsx @@ -0,0 +1,82 @@ +import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; + +import { getTOCSnapshotSignature, TOCHeadingDefinition, TOCItem, TOCRegistryController } from './tocRegistry'; + +const TOCRegistrationContext = createContext(null); +const TOCItemsContext = createContext([]); +const TOC_READY_DELAY_MS = 500; + +export const TOCProvider = ({ children }: { children: React.ReactNode }): JSX.Element => { + const [items, setItems] = useState([]); + const publishRef = useRef(setItems); + publishRef.current = setItems; + + const controllerRef = useRef(); + if (!controllerRef.current) { + controllerRef.current = new TOCRegistryController( + (nextItems) => publishRef.current(nextItems), + (callback) => window.requestAnimationFrame(callback), + (commitId) => window.cancelAnimationFrame(commitId), + (id, registeredElements) => { + const existingElement = document.getElementById(id); + return Boolean(existingElement && !registeredElements.has(existingElement)); + } + ); + } + + const register = controllerRef.current.register; + const lastMarkedSignatureRef = useRef(''); + + useEffect(() => { + if (typeof window.performance?.clearMarks === 'function') { + window.performance.clearMarks('service_toc_ready'); + } + + return () => { + controllerRef.current?.dispose(); + }; + }, []); + + useEffect(() => { + if (!items.length) { + return undefined; + } + + const signature = getTOCSnapshotSignature(items); + if (signature === lastMarkedSignatureRef.current) { + return undefined; + } + + const timeoutId = window.setTimeout(() => { + if (typeof window.performance?.mark === 'function') { + window.performance.clearMarks('service_toc_ready'); + window.performance.mark('service_toc_ready'); + } + lastMarkedSignatureRef.current = signature; + }, TOC_READY_DELAY_MS); + + return () => window.clearTimeout(timeoutId); + }, [items]); + + return ( + + {children} + + ); +}; + +export const useTOCItems = (): TOCItem[] => useContext(TOCItemsContext); + +export const useTOCHeading = (definition: TOCHeadingDefinition): React.RefCallback => { + const register = useContext(TOCRegistrationContext); + const unregisterRef = useRef<(() => void) | undefined>(); + const { explicitId, label, level } = definition; + + return useCallback( + (element) => { + unregisterRef.current?.(); + unregisterRef.current = element && register ? register(element, { explicitId, label, level }) : undefined; + }, + [explicitId, label, level, register] + ); +}; diff --git a/app/components/TOC/tocRegistry.test.mjs b/app/components/TOC/tocRegistry.test.mjs new file mode 100644 index 0000000000..c71cdfae97 --- /dev/null +++ b/app/components/TOC/tocRegistry.test.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getTOCSnapshotSignature, normalizeTOCLabel, TOCRegistryController } from './tocRegistry.ts'; + +const createElement = (order) => ({ + id: '', + order, + compareDocumentPosition(other) { + if (this.order < other.order) { + return 4; + } + if (this.order > other.order) { + return 2; + } + return 0; + }, +}); + +const createHarness = (occupiedIds = new Set()) => { + let pendingCommit; + let nextCommitId = 0; + let latestItems = []; + let publishCount = 0; + const controller = new TOCRegistryController( + (items) => { + latestItems = items; + publishCount += 1; + }, + (callback) => { + pendingCommit = callback; + nextCommitId += 1; + return nextCommitId; + }, + () => { + pendingCommit = undefined; + }, + (id) => occupiedIds.has(id) + ); + + return { + controller, + flush() { + const commit = pendingCommit; + pendingCommit = undefined; + commit?.(); + }, + get items() { + return latestItems; + }, + get publishCount() { + return publishCount; + }, + }; +}; + +test('normalizes Latin and Cyrillic labels into stable anchor slugs', () => { + assert.equal(normalizeTOCLabel(' Св. возношение! '), 'св-возношение'); + assert.equal(normalizeTOCLabel('Psalm 50'), 'psalm-50'); + assert.equal(normalizeTOCLabel('***'), 'section'); +}); + +test('batches registrations and assigns duplicate labels deterministic IDs in DOM order', () => { + const harness = createHarness(); + const laterElement = createElement(20); + const earlierElement = createElement(10); + + harness.controller.register(laterElement, { label: 'Великая ектенья', level: 3 }); + harness.controller.register(earlierElement, { label: 'Великая ектенья', level: 2 }); + + assert.equal(harness.publishCount, 0); + harness.flush(); + + assert.equal(harness.publishCount, 1); + assert.deepEqual( + harness.items.map(({ value, level }) => ({ value, level })), + [ + { value: 'toc-великая-ектенья-1', level: 2 }, + { value: 'toc-великая-ектенья-2', level: 3 }, + ] + ); + assert.equal(earlierElement.id, 'toc-великая-ектенья-1'); + assert.equal(laterElement.id, 'toc-великая-ектенья-2'); +}); + +test('assigns collision-free deterministic IDs to one hundred repeated headings', () => { + const harness = createHarness(); + const elements = Array.from({ length: 100 }, (_, index) => createElement(index)); + + for (const element of elements.toReversed()) { + harness.controller.register(element, { label: 'Повтор', level: 3 }); + } + harness.flush(); + + assert.equal(new Set(harness.items.map(({ value }) => value)).size, 100); + assert.equal(harness.items[0].value, 'toc-повтор-1'); + assert.equal(harness.items[99].value, 'toc-повтор-100'); +}); + +test('renumbers headings deterministically when an earlier heading mounts or unmounts', () => { + const harness = createHarness(); + const secondElement = createElement(20); + const unregisterSecond = harness.controller.register(secondElement, { label: 'Молитва', level: 3 }); + harness.flush(); + assert.equal(secondElement.id, 'toc-молитва-1'); + + const firstElement = createElement(10); + const unregisterFirst = harness.controller.register(firstElement, { label: 'Молитва', level: 2 }); + harness.flush(); + assert.deepEqual( + harness.items.map(({ value }) => value), + ['toc-молитва-1', 'toc-молитва-2'] + ); + + unregisterFirst(); + harness.flush(); + assert.deepEqual( + harness.items.map(({ value }) => value), + ['toc-молитва-1'] + ); + assert.equal(secondElement.id, 'toc-молитва-1'); + + unregisterSecond(); + harness.flush(); + assert.deepEqual(harness.items, []); +}); + +test('preserves explicit heading anchors and avoids IDs occupied by other content', () => { + const harness = createHarness(new Set(['toc-вход-1'])); + const generatedElement = createElement(10); + const explicitElement = createElement(20); + + harness.controller.register(generatedElement, { label: 'Вход', level: 2 }); + harness.controller.register(explicitElement, { explicitId: 'vhod', label: 'Вход', level: 3 }); + harness.flush(); + + assert.equal(generatedElement.id, 'toc-вход-2'); + assert.equal(explicitElement.id, 'vhod'); + assert.deepEqual( + harness.items.map(({ value }) => value), + ['toc-вход-2', 'vhod'] + ); +}); + +test('builds identical signatures for identical published snapshots', () => { + const items = [{ value: 'toc-молитва-1', label: 'Молитва', shortLabel: 'Молитва', level: 2 }]; + assert.equal(getTOCSnapshotSignature(items), getTOCSnapshotSignature(items.map((item) => ({ ...item })))); +}); diff --git a/app/components/TOC/tocRegistry.ts b/app/components/TOC/tocRegistry.ts new file mode 100644 index 0000000000..93234ebfd7 --- /dev/null +++ b/app/components/TOC/tocRegistry.ts @@ -0,0 +1,149 @@ +export interface TOCItem { + value: string; + label: string; + shortLabel: string; + level?: number; +} + +export interface TOCHeadingDefinition { + label: string; + level?: number; + explicitId?: string; +} + +type ScheduleCommit = (callback: () => void) => number; +type CancelCommit = (commitId: number) => void; +type IsIdOccupied = (id: string, registeredElements: ReadonlySet) => boolean; + +const DOCUMENT_POSITION_PRECEDING = 2; +const DOCUMENT_POSITION_FOLLOWING = 4; + +export const normalizeTOCLabel = (label: string): string => { + const normalizedLabel = typeof label.normalize === 'function' ? label.normalize('NFKC') : label; + let result = ''; + let pendingSeparator = false; + + for (const character of normalizedLabel.trim().toLowerCase()) { + const isNumber = character >= '0' && character <= '9'; + const isLetter = character.toLowerCase() !== character.toUpperCase(); + + if (isNumber || isLetter) { + if (pendingSeparator && result) { + result += '-'; + } + result += character; + pendingSeparator = false; + } else { + pendingSeparator = true; + } + } + + return result || 'section'; +}; + +export class TOCRegistryController { + private readonly registrations = new Map(); + private pendingCommitId: number | undefined; + private disposed = false; + + constructor( + private readonly publish: (items: TOCItem[]) => void, + private readonly scheduleCommit: ScheduleCommit, + private readonly cancelCommit: CancelCommit, + private readonly isIdOccupied: IsIdOccupied + ) {} + + register = (element: HTMLElement, definition: TOCHeadingDefinition): (() => void) => { + const registration = { ...definition, label: definition.label.trim() }; + if (!registration.label) { + return () => undefined; + } + + this.registrations.set(element, registration); + this.queueCommit(); + + return () => { + if (this.registrations.get(element) === registration) { + this.registrations.delete(element); + this.queueCommit(); + } + }; + }; + + dispose = (): void => { + this.disposed = true; + if (this.pendingCommitId !== undefined) { + this.cancelCommit(this.pendingCommitId); + this.pendingCommitId = undefined; + } + this.registrations.clear(); + }; + + private queueCommit = (): void => { + if (this.disposed || this.pendingCommitId !== undefined) { + return; + } + + this.pendingCommitId = this.scheduleCommit(() => { + this.pendingCommitId = undefined; + this.commit(); + }); + }; + + private commit = (): void => { + if (this.disposed) { + return; + } + + const registrations = Array.from(this.registrations.entries()).sort(([elementA], [elementB]) => { + const position = elementA.compareDocumentPosition(elementB); + if (position & DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (position & DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; + }); + const registeredElements = new Set(registrations.map(([element]) => element)); + const reservedIds = new Set( + registrations + .map(([, definition]) => definition.explicitId?.trim()) + .filter((id): id is string => Boolean(id)) + ); + const slugOccurrences = new Map(); + const items = registrations.map(([element, definition]) => { + const explicitId = definition.explicitId?.trim(); + let value = explicitId; + + if (!value) { + const slug = normalizeTOCLabel(definition.label); + let occurrence = (slugOccurrences.get(slug) || 0) + 1; + value = `toc-${slug}-${occurrence}`; + + while (reservedIds.has(value) || this.isIdOccupied(value, registeredElements)) { + occurrence += 1; + value = `toc-${slug}-${occurrence}`; + } + slugOccurrences.set(slug, occurrence); + } + + reservedIds.add(value); + if (element.id !== value) { + element.id = value; + } + + return { + value, + label: definition.label, + shortLabel: definition.label, + level: definition.level, + }; + }); + + this.publish(items); + }; +} + +export const getTOCSnapshotSignature = (items: TOCItem[]): string => + items.map(({ value, label, level }) => `${value}\u0000${label}\u0000${level || ''}`).join('\u0001'); diff --git a/app/components/Tooltip/Tooltip.tsx b/app/components/Tooltip/Tooltip.tsx index 9c6bcb0a60..555fce2697 100644 --- a/app/components/Tooltip/Tooltip.tsx +++ b/app/components/Tooltip/Tooltip.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { Tooltip as Tippy } from 'react-tippy'; import 'react-tippy/dist/tippy.css'; + +import { OMIT_FROM_HEADING_LABEL } from 'components/Typography/headingLabel'; + const Tooltip = ({ children }) => ( @@ -12,4 +15,6 @@ const Tooltip = ({ children }) => ( ); +Tooltip[OMIT_FROM_HEADING_LABEL] = true; + export default Tooltip; diff --git a/app/components/Typography/Typography.tsx b/app/components/Typography/Typography.tsx index c43acbb332..b6def0db8f 100644 --- a/app/components/Typography/Typography.tsx +++ b/app/components/Typography/Typography.tsx @@ -1,65 +1,31 @@ -import React, { useEffect, useRef } from 'react'; +import React from 'react'; + +import { getHeadingLabel } from './headingLabel'; import ScriptEditorInput from 'components/ScriptEditor/ScriptEditorInput'; -import type { TOCItem } from 'state/TOCState'; +import { useTOCHeading } from 'components/TOC/TOCProvider'; interface TypographyProps { children?: React.ReactNode; } -const useAddToTOC = (title: React.ReactNode, level?: number): string => { - const randomNumberRef = useRef(Math.floor(Math.random() * 100)); - const domId = - typeof title === 'string' - ? title - : Array.isArray(title) - ? title.filter((i) => typeof i === 'string').join(' ') - : null; - - const processedDomId = domId ? `r-${domId}-${randomNumberRef.current}` : ''; - - useEffect(() => { - if (!processedDomId || !domId) { - return undefined; - } - - const item: TOCItem = { - value: processedDomId, - label: domId, - shortLabel: domId, - level, - }; - window.TOC = window.TOC || {}; - window.TOC[processedDomId] = item; - - return () => { - if (window.TOC?.[processedDomId] === item) { - Reflect.deleteProperty(window.TOC, processedDomId); - } - }; - }, [domId, level, processedDomId]); - - if (!domId) { - return ''; - } - return processedDomId; -}; - export const H1 = ({ children }: TypographyProps): JSX.Element =>

{children}

; -export const H2 = ({ children }: TypographyProps): JSX.Element => { - const domId = useAddToTOC(children, 2); +export const H2 = ({ children, id, ...headingProps }: React.ComponentPropsWithoutRef<'h2'>): JSX.Element => { + const label = getHeadingLabel(children); + const ref = useTOCHeading({ explicitId: id, label, level: 2 }); return ( -

+

{children}

); }; -export const H3 = ({ children }: TypographyProps): JSX.Element => { - const domId = useAddToTOC(children, 3); +export const H3 = ({ children, id, ...headingProps }: React.ComponentPropsWithoutRef<'h3'>): JSX.Element => { + const label = getHeadingLabel(children); + const ref = useTOCHeading({ explicitId: id, label, level: 3 }); return ( -

+

{children}

); diff --git a/app/components/Typography/headingLabel.test.mjs b/app/components/Typography/headingLabel.test.mjs new file mode 100644 index 0000000000..8036a4952f --- /dev/null +++ b/app/components/Typography/headingLabel.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import React from 'react'; + +import { getHeadingLabel, OMIT_FROM_HEADING_LABEL } from './headingLabel.ts'; + +const HiddenAnnotation = ({ children }) => children; +HiddenAnnotation[OMIT_FROM_HEADING_LABEL] = true; + +test('builds a visible heading label from text and presentational markup', () => { + const children = [ + React.createElement('b', { key: 'open' }, '['), + ' Молитва ', + React.createElement('br', { key: 'break' }), + React.createElement('em', { key: 'detail' }, 'перед чтением'), + ]; + + assert.equal(getHeadingLabel(children), '[ Молитва перед чтением'); +}); + +test('omits tooltip-style annotations from heading labels', () => { + const children = [ + 'Чтение Апостола ', + React.createElement(HiddenAnnotation, { key: 'note' }, 'Длинное примечание для всплывающей подсказки'), + ]; + + assert.equal(getHeadingLabel(children), 'Чтение Апостола'); +}); diff --git a/app/components/Typography/headingLabel.ts b/app/components/Typography/headingLabel.ts new file mode 100644 index 0000000000..b96915ccec --- /dev/null +++ b/app/components/Typography/headingLabel.ts @@ -0,0 +1,27 @@ +import React from 'react'; + +export const OMIT_FROM_HEADING_LABEL = Symbol('omitFromHeadingLabel'); + +interface HeadingLabelComponent { + [OMIT_FROM_HEADING_LABEL]?: boolean; +} + +export const getHeadingLabel = (children: React.ReactNode): string => + React.Children.toArray(children) + .map((child) => { + if (typeof child === 'string' || typeof child === 'number') { + return String(child); + } + if (React.isValidElement<{ children?: React.ReactNode }>(child)) { + const component = child.type as HeadingLabelComponent; + if (component[OMIT_FROM_HEADING_LABEL]) { + return ''; + } + return getHeadingLabel(child.props.children); + } + return ''; + }) + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); diff --git a/app/containers/HabitTracker/trackedPrayerTimeOfDay.test.mjs b/app/containers/HabitTracker/trackedPrayerTimeOfDay.test.mjs new file mode 100644 index 0000000000..6ba2b32d86 --- /dev/null +++ b/app/containers/HabitTracker/trackedPrayerTimeOfDay.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getPrayerTrackingPolicy } from './trackedPrayerTimeOfDay.ts'; + +test('subscribes to habit settings only for logged-in users viewing tracked services', () => { + assert.deepEqual(getPrayerTrackingPolicy('matins', true), { + trackedPrayerTimeOfDay: 'morning', + subscribeToSettings: true, + }); + assert.deepEqual(getPrayerTrackingPolicy('vespers', true), { + trackedPrayerTimeOfDay: 'evening', + subscribeToSettings: true, + }); + assert.deepEqual(getPrayerTrackingPolicy('zlatoust', true), { + trackedPrayerTimeOfDay: undefined, + subscribeToSettings: false, + }); + assert.deepEqual(getPrayerTrackingPolicy('matins', false), { + trackedPrayerTimeOfDay: 'morning', + subscribeToSettings: false, + }); +}); diff --git a/app/containers/HabitTracker/trackedPrayerTimeOfDay.ts b/app/containers/HabitTracker/trackedPrayerTimeOfDay.ts index 6f6a42e479..7c1c40be96 100644 --- a/app/containers/HabitTracker/trackedPrayerTimeOfDay.ts +++ b/app/containers/HabitTracker/trackedPrayerTimeOfDay.ts @@ -5,6 +5,15 @@ export const trackedPrayerTimeOfDayByServiceId: Partial { + const trackedPrayerTimeOfDay = trackedPrayerTimeOfDayByServiceId[serviceId]; + + return { + trackedPrayerTimeOfDay, + subscribeToSettings: Boolean(isLoggedIn && trackedPrayerTimeOfDay), + }; +}; + export const trackedPrayerLabelByTimeOfDay: Record = { morning: 'утреннюю', evening: 'вечернюю', diff --git a/app/containers/HabitTracker/usePrayerTimer.ts b/app/containers/HabitTracker/usePrayerTimer.ts index 80eb9f10c0..c42b736dde 100644 --- a/app/containers/HabitTracker/usePrayerTimer.ts +++ b/app/containers/HabitTracker/usePrayerTimer.ts @@ -4,7 +4,7 @@ import { useSession } from 'containers/AuthProvider'; import { api } from '../../../convex/_generated/api'; -import { trackedPrayerTimeOfDayByServiceId } from './trackedPrayerTimeOfDay'; +import { getPrayerTrackingPolicy } from './trackedPrayerTimeOfDay'; import type { TrackedPrayerTimeOfDay } from './trackedPrayerTimeOfDay'; const PRAYER_THRESHOLD_SECONDS = 240; // 4 minutes @@ -27,8 +27,8 @@ interface QueuedSession { export const usePrayerTimer = ({ date, serviceId }: PrayerTimerProps) => { const session = useSession(); const isLoggedIn = !!session.profile; - const settings = useQuery(api.habitTracker.getSettings); - const trackedPrayerTimeOfDay = trackedPrayerTimeOfDayByServiceId[serviceId]; + const { trackedPrayerTimeOfDay, subscribeToSettings } = getPrayerTrackingPolicy(serviceId, isLoggedIn); + const settings = useQuery(api.habitTracker.getSettings, subscribeToSettings ? undefined : 'skip'); const sessions = useQuery( api.habitTracker.getSessionsForRange, isLoggedIn && trackedPrayerTimeOfDay && settings?.habitTracker ? { startDate: date, endDate: date } : 'skip' diff --git a/app/containers/Main/Services.tsx b/app/containers/Main/Services.tsx index 0bac7ff017..3a48a429f1 100644 --- a/app/containers/Main/Services.tsx +++ b/app/containers/Main/Services.tsx @@ -2,19 +2,21 @@ import React, { useState } from 'react'; import { useTheme } from '@emotion/react'; import { css } from '@emotion/css'; import { Link, useLocation } from 'react-router-dom'; +import groupBy from 'lodash.groupby'; +import { useRecoilState } from 'recoil'; + +import { preloadServiceRoute } from '../../routeLoaders'; + +import SectionHeading from './SectionHeading'; + import RightIcon from 'components/svgs/RightIcon'; import TrashIcon from 'components/svgs/TrashIcon'; import ButtonBox from 'components/ButtonBox/ButtonBox'; import useServices from 'containers/Service/Texts/Texts'; -import groupBy from 'lodash.groupby'; import customPrayersState from 'state/customPrayersState'; -import { useRecoilState } from 'recoil'; import PlusIcon from 'components/svgs/PlusIcon'; import CustomPrayerInput from 'components/CustomPrayers/CustomPrayerInput'; import customPrayerInputState from 'state/customPrayerInputState'; -import { truncate } from 'lodash'; - -import SectionHeading from './SectionHeading'; const OptionalLink = ({ enabled, ...rest }) => enabled ? ( @@ -87,6 +89,9 @@ const Services = ({ date, readings }) => { {servicesForGroup.map((service) => ( { `} > { { const theme = useTheme(); const setPendingUpdate = useSetRecoilState(pendingUpdateState); const queryClient = useQueryClient(); + const refreshApplicationData = async () => { + const newVersion = await checkVersion(); + if (newVersion) { + setPendingUpdate(newVersion); + } + await precache(true); + await queryClient.refetchQueries(); + }; + if (!menuShown) { return null; } @@ -181,13 +190,8 @@ const SettingsMenu = () => { className={css` text-decoration: underline; `} - onClick={async () => { - const newVersion = await checkVersion(); - if (newVersion) { - setPendingUpdate(newVersion); - } - await precache(true); - await queryClient.refetchQueries(); + onClick={() => { + void refreshApplicationData(); }} > Обновить данные diff --git a/app/containers/Service/LangContext.tsx b/app/containers/Service/LangContext.tsx index 4c1a08ddda..b3a3929c47 100644 --- a/app/containers/Service/LangContext.tsx +++ b/app/containers/Service/LangContext.tsx @@ -1,3 +1,13 @@ import { createContext } from 'react'; -export const LangContext = createContext(null); +export interface LangContextValue { + lang: string; + langA: string; + langB: string; +} + +export const LangContext = createContext({ + lang: 'ru', + langA: 'ru', + langB: 'csj', +}); diff --git a/app/containers/Service/MDXProvider.tsx b/app/containers/Service/MDXProvider.tsx index 648361d857..5c2d0f16d3 100644 --- a/app/containers/Service/MDXProvider.tsx +++ b/app/containers/Service/MDXProvider.tsx @@ -1,12 +1,15 @@ import React, { useRef } from 'react'; import { MDXProvider as OriginalMDXProvider } from '@mdx-js/react'; + import If, { Then, Else } from 'components/If/If'; import Tooltip from 'components/Tooltip/Tooltip'; import MdxLoader from 'containers/Service/Texts/MdxLoader'; +import { MdxLoaderRuntimeProvider } from 'containers/Service/Texts/MdxLoaderRuntime'; import Parts from 'components/Parts/Parts'; import './mdx.css'; import useAudio from 'hooks/useAudio'; import { H1, H2, H3, H4, P, Petit, PetitInline, Red, Super } from 'components/Typography/Typography'; +import { ScriptEditorStateProvider } from 'components/ScriptEditor/ScriptEditorInput'; const mapping = { h1: H1, @@ -28,13 +31,28 @@ const mapping = { MdxLoader, Parts, - wrapper: (props) => { - const ref = useRef(); - useAudio(ref); - return
; - }, + wrapper: (props) =>
, +}; + +const AudioRoot = ({ children }: { children: React.ReactNode }): JSX.Element => { + const ref = useRef(null); + useAudio(ref); + + return ( +
+ {children} +
+ ); }; -const MDXProvider = ({ children }) => {children}; +const MDXProvider = ({ children }): JSX.Element => ( + + + + {children} + + + +); export default MDXProvider; diff --git a/app/containers/Service/Service.tsx b/app/containers/Service/Service.tsx index a4af414e38..4e3840d978 100644 --- a/app/containers/Service/Service.tsx +++ b/app/containers/Service/Service.tsx @@ -1,7 +1,7 @@ import { getFeastInfo } from 'domain/getDayInfo'; import * as Sentry from '@sentry/react'; -import React, { Suspense, useState, useEffect, useContext } from 'react'; +import React, { Suspense, useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { Navigate, useLocation, useNavigate, useParams } from 'react-router-dom'; import { css } from '@emotion/css'; import useDay from 'hooks/useDay'; @@ -23,6 +23,7 @@ import Pencil from 'components/svgs/Pencil'; import CustomPrayerInput from 'components/CustomPrayers/CustomPrayerInput'; import { usePrayerTimer } from 'containers/HabitTracker/usePrayerTimer'; import PostPrayerPrompt from 'containers/HabitTracker/PostPrayerPrompt'; +import { TOCProvider } from 'components/TOC/TOCProvider'; import LanguageSwitcher from './LanguageSwitcher'; import TOCSwitcher from './TOCSwitcher'; @@ -35,10 +36,28 @@ import { ServiceContext } from './ServiceContext'; const reloadOnFailedImport = (e) => { console.warn('Imported asset not available, probably time to re-deploy', e); Sentry.captureException?.(e); + throw e; }; const toUpperCase = (name) => name.charAt(0).toUpperCase() + name.slice(1); +const ServiceCommitMarker = ({ renderKey }: { renderKey: string }): null => { + useEffect(() => { + if (typeof performance === 'undefined' || !performance.mark) { + return; + } + + performance.clearMarks?.('service_complete_commit'); + try { + performance.mark('service_complete_commit', { detail: { renderKey } }); + } catch { + performance.mark('service_complete_commit'); + } + }, [renderKey]); + + return null; +}; + const Service = () => { const { serviceId: originalServiceId = '', date = '', prayerId } = useParams<'date' | 'prayerId' | 'serviceId'>(); const { data: day } = useDay(date); @@ -82,22 +101,34 @@ const Service = () => { serviceId = originalServiceId.split('/')[0]; } - const [TextComponent, setTextComponent] = useState(); - useEffect(() => { - if (serviceId) { - const serviceIdUpper = toUpperCase(serviceId); - const Component = React.lazy(async () => - import(`./Texts/${serviceIdUpper}/index.dyn.tsx`).catch(reloadOnFailedImport) - ); - setTextComponent(Component); + const TextComponent = useMemo(() => { + if (!serviceId) { + return null; } - }, [serviceId]); - useEffect(() => { - // Reset TOC on service change - window.TOC = {}; - }, [serviceId]); + const serviceIdUpper = toUpperCase(serviceId); + return React.lazy(async () => { + try { + return await import(`./Texts/${serviceIdUpper}/index.dyn.tsx`); + } catch (error) { + return reloadOnFailedImport(error); + } + }); + }, [serviceId]); const currentServiceId = serviceId || originalServiceId; + const serviceRenderKey = [ + date, + currentServiceId, + service?.lang ? langState?.lang || 'ru' : 'ru', + service?.lang ? langState?.langA || '' : '', + service?.lang ? langState?.langB || '' : '', + ].join(':'); + useLayoutEffect(() => { + if (typeof performance !== 'undefined') { + performance.clearMarks?.('service_complete_commit'); + } + }, [serviceRenderKey]); + const { completionPromptTimeOfDay, dismissCompletionPrompt } = usePrayerTimer({ date, serviceId: currentServiceId, @@ -140,7 +171,7 @@ const Service = () => { /> )} {service?.lang && } - {!service?.hideTOC && } + {!service?.hideTOC && } {service?.scriptEditor && } ); @@ -170,66 +201,73 @@ const Service = () => { return ( - - {service?.scriptEditor && ( - <> - - - )} - - - - <> -
- {service?.warn && ( - - Изменяемые части богослужения составлены нашим роботом-уставщиком. Он иногда - ошибается. За наиболее точной информацией обращайтесь к{' '} - - богослужебным указаниям. - {' '} - Если вы обнаружили ошибку, пожалуйста,{' '} - - напишите нам - - - )} - - - }> - {TextComponent && } - - -
- -
- {customPrayerInputShown && ( - { - setCustomPrayerInputShown(false); - }} - /> - )} - -
+ + + {service?.scriptEditor && ( + <> + + + )} + + + + <> +
+ {service?.warn && ( + + Изменяемые части богослужения составлены нашим роботом-уставщиком. Он иногда + ошибается. За наиболее точной информацией обращайтесь к{' '} + + богослужебным указаниям. + {' '} + Если вы обнаружили ошибку, пожалуйста,{' '} + + напишите нам + + + )} + + + }> + {TextComponent && ( + <> + + + + )} + + +
+ +
+ {customPrayerInputShown && ( + { + setCustomPrayerInputShown(false); + }} + /> + )} + +
+
); diff --git a/app/containers/Service/ServiceContext.tsx b/app/containers/Service/ServiceContext.tsx index 9345c7396e..4df1f6e027 100644 --- a/app/containers/Service/ServiceContext.tsx +++ b/app/containers/Service/ServiceContext.tsx @@ -1,3 +1,7 @@ import { createContext } from 'react'; -export const ServiceContext = createContext(null); +interface ServiceContextValue { + serviceId?: string; +} + +export const ServiceContext = createContext(null); diff --git a/app/containers/Service/TOCSwitcher.tsx b/app/containers/Service/TOCSwitcher.tsx index de19f179b6..791916ffd9 100644 --- a/app/containers/Service/TOCSwitcher.tsx +++ b/app/containers/Service/TOCSwitcher.tsx @@ -1,32 +1,23 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { css } from '@emotion/css'; -import { useRecoilValue } from 'recoil'; import SelectBox from '../../components/SelectBox/SelectBox'; -import TOCState from 'state/TOCState'; +import { useTOCItems } from 'components/TOC/TOCProvider'; -interface TOCSwitcherProps { - lang: string; - service?: unknown; -} - -const TOCSwitcher = ({ lang }: TOCSwitcherProps): JSX.Element => { - const TOC = useRecoilValue(TOCState); +const TOCSwitcher = (): JSX.Element => { + const TOC = useTOCItems(); const [activeItem, setActiveItem] = useState(''); + const observerRef = useRef(null); + const observedNodesRef = useRef(new Map()); useEffect(() => { - if (typeof window.IntersectionObserver !== 'function' || !TOC.length) { + if (typeof window.IntersectionObserver !== 'function') { return undefined; } - let disposed = false; - const pendingTimeouts = new Set(); const observer = new IntersectionObserver( (entries) => { - if (disposed) { - return; - } entries.forEach((entry) => { if (entry.isIntersecting && entry.target.id) { setActiveItem(entry.target.id); @@ -38,34 +29,47 @@ const TOCSwitcher = ({ lang }: TOCSwitcherProps): JSX.Element => { threshold: 0.3, } ); + observerRef.current = observer; - // The MDX content can mount after the TOC data, so retry until each heading exists. - const observeOrCue = (nodeId: string) => { - if (disposed) { - return; - } - const node = document.getElementById(nodeId); + return () => { + observer.disconnect(); + observerRef.current = null; + observedNodesRef.current.clear(); + }; + }, []); + + useEffect(() => { + const observer = observerRef.current; + if (!observer) { + return; + } + + const nextNodes = new Map(); + TOC.forEach(({ value }) => { + const node = document.getElementById(value); if (node) { - observer.observe(node); - return; + nextNodes.set(value, node); } + }); - const timeoutId = window.setTimeout(() => { - pendingTimeouts.delete(timeoutId); - observeOrCue(nodeId); - }, 500); - pendingTimeouts.add(timeoutId); - }; - - TOC.forEach(({ value }) => observeOrCue(value)); + observedNodesRef.current.forEach((node, id) => { + if (nextNodes.get(id) !== node) { + observer.unobserve(node); + } + }); + nextNodes.forEach((node, id) => { + if (observedNodesRef.current.get(id) !== node) { + observer.observe(node); + } + }); + observedNodesRef.current = nextNodes; + }, [TOC]); - return () => { - disposed = true; - pendingTimeouts.forEach((timeoutId) => window.clearTimeout(timeoutId)); - pendingTimeouts.clear(); - observer.disconnect(); - }; - }, [lang, TOC]); + useEffect(() => { + if (activeItem && !TOC.some(({ value }) => value === activeItem)) { + setActiveItem(''); + } + }, [activeItem, TOC]); return ( { setActiveItem(anchorID); try { domNode.scrollIntoView({ block: 'center' }); - } catch (error) { + } catch { // fallback to prevent browser crashing domNode.scrollIntoView(); } diff --git a/app/containers/Service/Texts/MdxLoader.tsx b/app/containers/Service/Texts/MdxLoader.tsx index 2b4bc1ab9d..7046042706 100644 --- a/app/containers/Service/Texts/MdxLoader.tsx +++ b/app/containers/Service/Texts/MdxLoader.tsx @@ -4,49 +4,93 @@ import { css } from '@emotion/css'; import Button from 'components/Button/Button'; import SolidSection from 'components/SolidSection/SolidSection'; import { useTheme } from '@emotion/react'; -import { useRecoilState, useRecoilValue } from 'recoil'; -import scriptEditorIsActiveState from 'state/scriptEditorIsActiveState'; -import disabledPrayersState from 'state/disabledPrayersState'; import Visibility from 'components/svgs/Visibility'; import VisibilityOff from 'components/svgs/VisibilityOff'; import CustomPrayers from 'components/CustomPrayers/CustomPrayers'; import { LangContext } from '../LangContext'; -import { ServiceContext } from '../ServiceContext'; -import currentScriptVersionState from 'state/currentScriptVersion'; +import { useMdxLoaderRuntime } from './MdxLoaderRuntime'; +import { createSuspenseResourceCache } from './suspenseResourceCache'; export const MdxLoaderContext = createContext(0); -const catchFailedImport = (e) => { - console.warn('Loading mdx file failed', e); - Sentry.captureException?.(e); +interface MdxLoaderProps { + isCustomPrayer?: boolean; + lang?: string; + langOverride?: string; + src: string; + [key: string]: unknown; +} + +const catchFailedImport = (error: unknown): never => { + console.warn('Loading mdx file failed', error); + Sentry.captureException?.(error); + throw error instanceof Error ? error : new Error(String(error)); }; /** * The world is not without good people: https://twitter.com/JLarky/status/1585448425813725184 */ -const componentCache = new Map(); +const componentCache = createSuspenseResourceCache>(); + +interface MdxModule { + default: React.ComponentType; +} -const LazyComponent = (props) => { - const key = `${props.src}${props.lang || 'ru'}`; - const Component = componentCache.get(key); - if (Component) { - return ; +const loadMdxModule = async (src: string, language: string): Promise => { + if (src.startsWith('Liturgies/Katekhumen/') && language === 'ru') { + const relativeSource = src.slice('Liturgies/Katekhumen/'.length); + return (await import( + /* webpackMode: "lazy-once", webpackChunkName: "mdx-liturgy-katekhumen-ru" */ + `containers/Service/Texts/Liturgies/Katekhumen/${relativeSource}/ru.mdx` + )) as MdxModule; + } + if (src.startsWith('Liturgies/Katekhumen/') && language === 'csj') { + const relativeSource = src.slice('Liturgies/Katekhumen/'.length); + return (await import( + /* webpackMode: "lazy-once", webpackChunkName: "mdx-liturgy-katekhumen-csj" */ + `containers/Service/Texts/Liturgies/Katekhumen/${relativeSource}/csj.mdx` + )) as MdxModule; } - throw import(`containers/Service/Texts/${props.src}/${props.lang || 'ru'}.mdx`) - .then((x) => { - componentCache.set(key, x.default); - }) - .catch(catchFailedImport); + if (src.startsWith('Liturgies/Vernie/') && language === 'ru') { + const relativeSource = src.slice('Liturgies/Vernie/'.length); + return (await import( + /* webpackMode: "lazy-once", webpackChunkName: "mdx-liturgy-vernie-ru" */ + `containers/Service/Texts/Liturgies/Vernie/${relativeSource}/ru.mdx` + )) as MdxModule; + } + if (src.startsWith('Liturgies/Vernie/') && language === 'csj') { + const relativeSource = src.slice('Liturgies/Vernie/'.length); + return (await import( + /* webpackMode: "lazy-once", webpackChunkName: "mdx-liturgy-vernie-csj" */ + `containers/Service/Texts/Liturgies/Vernie/${relativeSource}/csj.mdx` + )) as MdxModule; + } + + return (await import( + /* webpackExclude: /Liturgies\/(?:Katekhumen|Vernie)\// */ + `containers/Service/Texts/${src}/${language}.mdx` + )) as MdxModule; }; -const MdxLoader = (props) => { +const LazyComponent = (props: MdxLoaderProps): JSX.Element => { + const language = props.lang || 'ru'; + const Component = componentCache.read(`${props.src}\0${language}`, async () => { + try { + const module = await loadMdxModule(props.src, language); + return module.default; + } catch (error) { + return catchFailedImport(error); + } + }); + + return ; +}; + +const MdxLoader = (props: MdxLoaderProps): JSX.Element | null => { const theme = useTheme(); - const serviceContext = useContext(ServiceContext); - const serviceId = serviceContext?.serviceId; - const currentScriptVersion = useRecoilValue(currentScriptVersionState(serviceId)); - const [scriptEditorIsActive] = useRecoilState(scriptEditorIsActiveState); - const [disabledPrayers, setDisabledPrayers] = useRecoilState(disabledPrayersState); + const { currentScriptVersion, disabledPrayers, scriptEditorIsActive, serviceId, setDisabledPrayers } = + useMdxLoaderRuntime(); const { lang, langA, langB } = useContext(LangContext); const nestingLevel = useContext(MdxLoaderContext); const langEffective = props.langOverride || lang; @@ -144,12 +188,14 @@ const MdxLoader = (props) => { ); } + if (isDisabled) { + return null; + } + return ( - !isDisabled && ( - - - - ) + + + ); }; diff --git a/app/containers/Service/Texts/MdxLoaderRuntime.tsx b/app/containers/Service/Texts/MdxLoaderRuntime.tsx new file mode 100644 index 0000000000..6f7525f13d --- /dev/null +++ b/app/containers/Service/Texts/MdxLoaderRuntime.tsx @@ -0,0 +1,46 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; + +import { ServiceContext } from '../ServiceContext'; + +import disabledPrayersState from 'state/disabledPrayersState'; +import currentScriptVersionState from 'state/currentScriptVersion'; +import scriptEditorIsActiveState from 'state/scriptEditorIsActiveState'; + +interface MdxLoaderRuntimeValue { + currentScriptVersion: string | null; + disabledPrayers: string[]; + scriptEditorIsActive: boolean; + serviceId?: string; + setDisabledPrayers: (prayerIds: string[]) => void; +} + +const readerRuntime: MdxLoaderRuntimeValue = { + currentScriptVersion: null, + disabledPrayers: [], + scriptEditorIsActive: false, + setDisabledPrayers: () => undefined, +}; + +const MdxLoaderRuntimeContext = createContext(readerRuntime); + +export const MdxLoaderRuntimeProvider = ({ children }: { children: React.ReactNode }): JSX.Element => { + const serviceId = useContext(ServiceContext)?.serviceId; + const currentScriptVersion = useRecoilValue(currentScriptVersionState(serviceId)); + const scriptEditorIsActive = Boolean(useRecoilValue(scriptEditorIsActiveState)); + const [disabledPrayers, setDisabledPrayers] = useRecoilState(disabledPrayersState); + const runtime = useMemo( + () => ({ + currentScriptVersion, + disabledPrayers, + scriptEditorIsActive, + serviceId, + setDisabledPrayers, + }), + [currentScriptVersion, disabledPrayers, scriptEditorIsActive, serviceId, setDisabledPrayers] + ); + + return {children}; +}; + +export const useMdxLoaderRuntime = (): MdxLoaderRuntimeValue => useContext(MdxLoaderRuntimeContext); diff --git a/app/containers/Service/Texts/suspenseResourceCache.test.mjs b/app/containers/Service/Texts/suspenseResourceCache.test.mjs new file mode 100644 index 0000000000..e399dde4e1 --- /dev/null +++ b/app/containers/Service/Texts/suspenseResourceCache.test.mjs @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createSuspenseResourceCache } from './suspenseResourceCache.ts'; + +const readSuspendedPromise = (cache, key, load) => { + try { + cache.read(key, load); + } catch (error) { + return error; + } + + assert.fail('the first read must suspend'); +}; + +test('deduplicates in-flight loads and preserves the resolved value', async () => { + const cache = createSuspenseResourceCache(); + let resolveLoad; + let loadCount = 0; + const load = () => { + loadCount += 1; + return new Promise((resolve) => { + resolveLoad = resolve; + }); + }; + + const firstPromise = readSuspendedPromise(cache, 'Shared/Ending\u0000ru', load); + const secondPromise = readSuspendedPromise(cache, 'Shared/Ending\u0000ru', load); + + assert.equal(firstPromise, secondPromise); + assert.equal(loadCount, 1); + + const component = () => null; + resolveLoad(component); + await firstPromise; + + assert.equal(cache.read('Shared/Ending\u0000ru', load), component); + assert.equal(loadCount, 1); +}); + +test('keeps source and language resources independent', async () => { + const cache = createSuspenseResourceCache(); + const russianComponent = () => null; + const slavonicComponent = () => null; + + const russianPromise = readSuspendedPromise(cache, 'Shared/Ending\u0000ru', async () => russianComponent); + const slavonicPromise = readSuspendedPromise(cache, 'Shared/Ending\u0000csj', async () => slavonicComponent); + + await Promise.all([russianPromise, slavonicPromise]); + + assert.equal( + cache.read('Shared/Ending\u0000ru', async () => slavonicComponent), + russianComponent + ); + assert.equal( + cache.read('Shared/Ending\u0000csj', async () => russianComponent), + slavonicComponent + ); +}); + +test('preserves a rejected load and does not retry it on later renders', async () => { + const cache = createSuspenseResourceCache(); + const importError = new Error('offline chunk is unavailable'); + let loadCount = 0; + const load = async () => { + loadCount += 1; + throw importError; + }; + + const rejectedPromise = readSuspendedPromise(cache, 'Shared/Ending\u0000ru', load); + await assert.rejects(rejectedPromise, (error) => error === importError); + + assert.throws( + () => cache.read('Shared/Ending\u0000ru', load), + (error) => error === importError + ); + assert.equal(loadCount, 1); +}); + +test('preserves synchronous loader failures without retrying', () => { + const cache = createSuspenseResourceCache(); + const importError = new Error('invalid module request'); + let loadCount = 0; + const load = () => { + loadCount += 1; + throw importError; + }; + + assert.throws( + () => cache.read('Shared/Ending\u0000ru', load), + (error) => error === importError + ); + assert.throws( + () => cache.read('Shared/Ending\u0000ru', load), + (error) => error === importError + ); + assert.equal(loadCount, 1); +}); diff --git a/app/containers/Service/Texts/suspenseResourceCache.ts b/app/containers/Service/Texts/suspenseResourceCache.ts new file mode 100644 index 0000000000..a157fb0701 --- /dev/null +++ b/app/containers/Service/Texts/suspenseResourceCache.ts @@ -0,0 +1,71 @@ +interface PendingResource { + status: 'pending'; + promise: Promise; +} + +interface ResolvedResource { + status: 'resolved'; + value: T; +} + +interface RejectedResource { + status: 'rejected'; + error: Error; +} + +type Resource = PendingResource | ResolvedResource | RejectedResource; + +export interface SuspenseResourceCache { + clear: () => void; + read: (key: string, load: () => Promise) => T; +} + +export const createSuspenseResourceCache = (): SuspenseResourceCache => { + const resources = new Map>(); + + return { + clear: () => { + resources.clear(); + }, + read: (key, load) => { + let resource = resources.get(key); + + if (!resource) { + let loadPromise: Promise; + + try { + loadPromise = load(); + } catch (error) { + const loadError = error instanceof Error ? error : new Error(String(error)); + resources.set(key, { status: 'rejected', error: loadError }); + throw loadError; + } + + const promise = loadPromise.then( + (value) => { + resources.set(key, { status: 'resolved', value }); + return value; + }, + (error: unknown) => { + const loadError = error instanceof Error ? error : new Error(String(error)); + resources.set(key, { status: 'rejected', error: loadError }); + throw loadError; + } + ); + + resource = { status: 'pending', promise }; + resources.set(key, resource); + } + + if (resource.status === 'pending') { + // React Suspense deliberately uses thrown promises to pause rendering. + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw resource.promise; + } + if (resource.status === 'rejected') { + throw resource.error; + } + return resource.value; + }, + }; +}; diff --git a/app/containers/Service/runtimeOptimizations.test.mjs b/app/containers/Service/runtimeOptimizations.test.mjs new file mode 100644 index 0000000000..0a5ce58558 --- /dev/null +++ b/app/containers/Service/runtimeOptimizations.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const serviceRoot = path.dirname(fileURLToPath(import.meta.url)); +const appRoot = path.resolve(serviceRoot, '../..'); +const readAppFile = (relativePath) => readFileSync(path.join(appRoot, relativePath), 'utf8'); + +test('derives the top-level service module during render and marks only complete Suspense commits', () => { + const serviceSource = readFileSync(path.join(serviceRoot, 'Service.tsx'), 'utf8'); + + assert.match(serviceSource, /const TextComponent = useMemo\(/); + assert.doesNotMatch(serviceSource, /setTextComponent/); + assert.match(serviceSource, /performance\.clearMarks\?\.\('service_complete_commit'\)/); + assert.match(serviceSource, /performance\.mark\('service_complete_commit', \{ detail: \{ renderKey \} \}\)/); + assert.match(serviceSource, /performance\.mark\('service_complete_commit'\)/); + assert.match( + serviceSource, + /[\s\S]*?<\/Suspense>/ + ); +}); + +test('keeps legacy script-editor storage identity while TOC labels become deterministic', () => { + const typographySource = readAppFile('components/Typography/Typography.tsx'); + + assert.match(typographySource, /ScriptEditorInput id=\{`\$\{window\.location\.href\}\$\{String\(children\)\}`\}/); + assert.doesNotMatch(typographySource, /ScriptEditorInput id=\{`\$\{window\.location\.href\}\$\{label\}`\}/); +}); + +test('owns MDX audio enhancement at one provider root instead of every nested wrapper', () => { + const providerSource = readFileSync(path.join(serviceRoot, 'MDXProvider.tsx'), 'utf8'); + + assert.equal(providerSource.match(/\buseAudio\(/g)?.length, 1); + assert.match(providerSource, /
/); + assert.match(providerSource, /wrapper: \(props\) =>
/); +}); + +test('does not schedule height work when swipeable views has no updater', () => { + const heightUpdaterSource = readAppFile('components/HeightUpdate/HeightUpdater.tsx'); + const guardPosition = heightUpdaterSource.indexOf('if (!updateHeight)'); + const timerPosition = heightUpdaterSource.indexOf('window.setTimeout'); + + assert.notEqual(guardPosition, -1); + assert.equal(guardPosition < timerPosition, true); + assert.match(heightUpdaterSource, /window\.clearTimeout\(timer\)/); +}); + +test('keeps editor-only UI and storage work out of the inactive heading path', () => { + const inputSource = readAppFile('components/ScriptEditor/ScriptEditorInput.tsx'); + const activeInputSource = readAppFile('components/ScriptEditor/ActiveScriptEditorInput.tsx'); + const loaderSource = readFileSync(path.join(serviceRoot, 'Texts/MdxLoader.tsx'), 'utf8'); + const loaderRuntimeSource = readFileSync(path.join(serviceRoot, 'Texts/MdxLoaderRuntime.tsx'), 'utf8'); + + assert.match(inputSource, /if \(!scriptEditorIsActive\) \{\s*return null;/); + assert.match(inputSource, /React\.lazy\(/); + assert.doesNotMatch(inputSource, /localStorage|useTheme|useState/); + assert.match(activeInputSource, /localStorage/); + assert.doesNotMatch(loaderSource, /useRecoil/); + assert.match(loaderRuntimeSource, /MdxLoaderRuntimeProvider/); +}); diff --git a/app/data/calendarQueryPolicy.test.mjs b/app/data/calendarQueryPolicy.test.mjs new file mode 100644 index 0000000000..540c37efd4 --- /dev/null +++ b/app/data/calendarQueryPolicy.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getCalendarQueryPolicy } from './calendarQueryPolicy.ts'; + +const now = new Date(2026, 6, 28, 12); + +test('keeps historical calendar responses fresh indefinitely', () => { + assert.deepEqual(getCalendarQueryPolicy('2026-07-27', now), { + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }); +}); + +test('refreshes current and future calendar responses after five minutes', () => { + for (const date of ['2026-07-28', '2026-07-29']) { + assert.deepEqual(getCalendarQueryPolicy(date, now), { + staleTime: 5 * 60 * 1000, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + }); + } +}); + +test('treats malformed dates as mutable instead of accidentally caching them forever', () => { + assert.equal(getCalendarQueryPolicy('', now).staleTime, 5 * 60 * 1000); + assert.equal(getCalendarQueryPolicy('27-07-2026', now).staleTime, 5 * 60 * 1000); + assert.equal(getCalendarQueryPolicy('2026-02-31', now).staleTime, 5 * 60 * 1000); +}); diff --git a/app/data/calendarQueryPolicy.ts b/app/data/calendarQueryPolicy.ts new file mode 100644 index 0000000000..7cff6884f5 --- /dev/null +++ b/app/data/calendarQueryPolicy.ts @@ -0,0 +1,50 @@ +const MUTABLE_DATE_STALE_TIME_MS = 5 * 60 * 1000; +const ISO_CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export interface CalendarQueryPolicy { + staleTime: number; + refetchOnMount: boolean; + refetchOnReconnect: boolean; + refetchOnWindowFocus: boolean; +} + +/** + * Past calendar data is immutable for normal navigation. Today's and future + * data can still change as the calendar API is updated, so it is refreshed + * after a short freshness window when the app remounts, reconnects, or + * returns to the foreground. Manual pull-to-refresh remains available for + * every date regardless of this policy. + */ +export const getCalendarQueryPolicy = (date: string, now: Date = new Date()): CalendarQueryPolicy => { + const currentDate = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + ].join('-'); + const dateParts = ISO_CALENDAR_DATE_PATTERN.exec(date); + const parsedDate = dateParts + ? new Date(Date.UTC(Number(date.slice(0, 4)), Number(date.slice(5, 7)) - 1, Number(date.slice(8, 10)))) + : null; + const isValidCalendarDate = + parsedDate !== null && + parsedDate.getUTCFullYear() === Number(date.slice(0, 4)) && + parsedDate.getUTCMonth() === Number(date.slice(5, 7)) - 1 && + parsedDate.getUTCDate() === Number(date.slice(8, 10)); + const isHistoricalDate = isValidCalendarDate && date < currentDate; + + if (isHistoricalDate) { + return { + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }; + } + + return { + staleTime: MUTABLE_DATE_STALE_TIME_MS, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + }; +}; diff --git a/app/data/readingQueryPolicy.test.mjs b/app/data/readingQueryPolicy.test.mjs new file mode 100644 index 0000000000..855bb809fc --- /dev/null +++ b/app/data/readingQueryPolicy.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { getReadingQueryPlan } from './readingQueryPolicy.ts'; + +test('does not fan out individual default-reading requests while the bulk response is pending', () => { + assert.deepEqual(getReadingQueryPlan('default', 'pending', false), { + fetchBulkReadings: true, + fetchIndividualReading: false, + useBulkReading: false, + }); +}); + +test('uses a default reading supplied by the bulk response without an individual request', () => { + assert.deepEqual(getReadingQueryPlan('default', 'success', true), { + fetchBulkReadings: true, + fetchIndividualReading: false, + useBulkReading: true, + }); +}); + +test('falls back to an individual request when bulk default readings are unavailable', () => { + for (const status of ['success', 'error']) { + assert.deepEqual(getReadingQueryPlan('default', status, false), { + fetchBulkReadings: true, + fetchIndividualReading: true, + useBulkReading: false, + }); + } +}); + +test('loads an explicitly selected translation directly without observing bulk readings', () => { + assert.deepEqual(getReadingQueryPlan('91Slavic', 'pending', false), { + fetchBulkReadings: false, + fetchIndividualReading: true, + useBulkReading: false, + }); +}); diff --git a/app/data/readingQueryPolicy.ts b/app/data/readingQueryPolicy.ts new file mode 100644 index 0000000000..54003ae526 --- /dev/null +++ b/app/data/readingQueryPolicy.ts @@ -0,0 +1,28 @@ +export type BulkReadingQueryStatus = 'pending' | 'error' | 'success'; + +export interface ReadingQueryPlan { + fetchBulkReadings: boolean; + fetchIndividualReading: boolean; + useBulkReading: boolean; +} + +/** + * The date-level readings response already contains the default translation. + * An individual request is only a fallback when that response has settled + * without the requested reading. Non-default translations are never supplied + * by the bulk endpoint and can start immediately. + */ +export const getReadingQueryPlan = ( + translation: string, + bulkQueryStatus: BulkReadingQueryStatus, + hasBulkReading: boolean +): ReadingQueryPlan => { + const fetchBulkReadings = translation === 'default'; + const useBulkReading = fetchBulkReadings && hasBulkReading; + + return { + fetchBulkReadings, + fetchIndividualReading: !fetchBulkReadings || (bulkQueryStatus !== 'pending' && !useBulkReading), + useBulkReading, + }; +}; diff --git a/app/hooks/useAudio.ts b/app/hooks/useAudio.ts index 2622679076..e0afe53dcd 100644 --- a/app/hooks/useAudio.ts +++ b/app/hooks/useAudio.ts @@ -1,5 +1,6 @@ import { useTheme } from '@emotion/react'; -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; +import type { RefObject } from 'react'; import { css } from '@emotion/css'; const formatTime = (seconds) => new Date(1000 * parseInt(seconds || 0)).toISOString().substr(14, 5); @@ -239,38 +240,68 @@ const augmentAudio = (audioElement, theme) => { }; }; -const useAudio = (ref) => { +const useAudio = (ref: RefObject) => { const theme = useTheme(); - const cleanups = useRef(new Map void>()); useEffect(() => { - if (!ref?.current) { - return; + const root = ref?.current; + if (!root) { + return undefined; } - const audioElements = new Set(ref.current.querySelectorAll('audio')); - cleanups.current.forEach((cleanup, audioElement) => { - if (!audioElements.has(audioElement)) { - cleanup(); - cleanups.current.delete(audioElement); + const cleanups = new Map void>(); + const enhanceAudioElement = (audioElement: HTMLAudioElement) => { + if (audioElement.closest('[data-audio-root]') !== root || cleanups.has(audioElement)) { + return; } - }); - audioElements.forEach((audioElement) => { - if (!cleanups.current.has(audioElement)) { - const cleanup = augmentAudio(audioElement, theme); - if (cleanup) { - cleanups.current.set(audioElement, cleanup); + + const cleanup = augmentAudio(audioElement, theme); + if (cleanup) { + cleanups.set(audioElement, cleanup); + } + }; + const enhanceAudioInNode = (node: Node) => { + if (!(node instanceof Element)) { + return; + } + if (node instanceof HTMLAudioElement) { + enhanceAudioElement(node); + } + node.querySelectorAll('audio').forEach(enhanceAudioElement); + }; + const removeDetachedAudio = () => { + cleanups.forEach((cleanup, audioElement) => { + if (!root.contains(audioElement)) { + cleanup(); + cleanups.delete(audioElement); + } + }); + }; + + enhanceAudioInNode(root); + + const observer = new MutationObserver((mutations) => { + let mayHaveRemovedAudio = false; + mutations.forEach((mutation) => { + mutation.addedNodes.forEach(enhanceAudioInNode); + if (mutation.removedNodes.length > 0) { + mayHaveRemovedAudio = true; } + }); + + if (mayHaveRemovedAudio) { + removeDetachedAudio(); } }); - }); + observer.observe(root, { childList: true, subtree: true }); - useEffect( - () => () => { - cleanups.current.forEach((cleanup) => cleanup()); - cleanups.current.clear(); - }, - [] - ); + return () => { + observer.disconnect(); + cleanups.forEach((cleanup) => { + cleanup(); + }); + cleanups.clear(); + }; + }, [ref, theme]); }; export default useAudio; diff --git a/app/hooks/useDay.ts b/app/hooks/useDay.ts index de650164b2..2a5e9493eb 100644 --- a/app/hooks/useDay.ts +++ b/app/hooks/useDay.ts @@ -4,6 +4,7 @@ import type { UseQueryResult } from '@tanstack/react-query'; import { getFeastInfo, getLentInfo } from 'domain/getDayInfo'; import type { Day, DayApiResponse } from 'data/contracts'; import { queryKeys } from 'data/queryKeys'; +import { getCalendarQueryPolicy } from 'data/calendarQueryPolicy'; import cachedFetch from 'utils/cachedFetch'; export async function fetchDay(date: string): Promise { @@ -43,6 +44,7 @@ const useDay = (date: string): UseQueryResult => queryKey: queryKeys.day(date), queryFn: async () => fetchDay(date), retry: false, + ...getCalendarQueryPolicy(date), }); export default useDay; diff --git a/app/hooks/useExternalDay.ts b/app/hooks/useExternalDay.ts index fdd982e57e..87cc141776 100644 --- a/app/hooks/useExternalDay.ts +++ b/app/hooks/useExternalDay.ts @@ -5,6 +5,7 @@ import cachedFetch from 'utils/cachedFetch'; import useDay from 'hooks/useDay'; import type { ExternalDay, ReadingVersesByType } from 'data/contracts'; import { queryKeys } from 'data/queryKeys'; +import { getCalendarQueryPolicy } from 'data/calendarQueryPolicy'; const fetchExternalDay = async (date: string, verses?: ReadingVersesByType): Promise => cachedFetch( @@ -21,6 +22,7 @@ const useExternalDay = (date: string): UseQueryResult => { queryFn: async () => fetchExternalDay(date, readings), retry: false, enabled: dayQuery.isFetched, + ...getCalendarQueryPolicy(date), }); }; diff --git a/app/hooks/useParts.ts b/app/hooks/useParts.ts index 6d0a28f75c..3737495ebb 100644 --- a/app/hooks/useParts.ts +++ b/app/hooks/useParts.ts @@ -4,6 +4,7 @@ import type { UseQueryResult } from '@tanstack/react-query'; import cachedFetch from 'utils/cachedFetch'; import type { PartsResponse } from 'data/contracts'; import { queryKeys } from 'data/queryKeys'; +import { getCalendarQueryPolicy } from 'data/calendarQueryPolicy'; export async function fetchParts(date: string, lang: string): Promise { return cachedFetch(`${process.env.API_HOST}/parts/${date}/${lang}`); @@ -14,6 +15,7 @@ const useParts = (date: string, lang: string): UseQueryResult fetchParts(date, lang), retry: false, + ...getCalendarQueryPolicy(date), }); export default useParts; diff --git a/app/hooks/useReading.ts b/app/hooks/useReading.ts index 7f6c34a334..38a1ec81ac 100644 --- a/app/hooks/useReading.ts +++ b/app/hooks/useReading.ts @@ -6,6 +6,7 @@ import useReadings from './useReadings'; import cachedFetch from 'utils/cachedFetch'; import type { ReadingResponse } from 'data/contracts'; import { queryKeys } from 'data/queryKeys'; +import { getReadingQueryPlan } from 'data/readingQueryPolicy'; export async function fetchReading( link: string, @@ -44,15 +45,19 @@ const useReading = ( date: string, translationPriority: string[] = [] ): ReadingQueryResult => { - const { data: readings } = useReadings(date); + const initialQueryPlan = getReadingQueryPlan(translation, 'pending', false); + const readingsQuery = useReadings(date, initialQueryPlan.fetchBulkReadings); + const bulkReading = readingsQuery.data?.[link]; + const queryPlan = getReadingQueryPlan(translation, readingsQuery.status, Boolean(bulkReading)); const readingQuery = useQuery({ queryKey: queryKeys.reading(link, translation), queryFn: async () => fetchReading(link, translation, translationPriority), retry: false, + enabled: queryPlan.fetchIndividualReading, }); - if (readings?.[link] && translation === 'default') { + if (queryPlan.useBulkReading) { return { - data: readings[link], + data: bulkReading, status: 'success', }; } diff --git a/app/hooks/useReadings.ts b/app/hooks/useReadings.ts index 2c67343224..64a8c6d817 100644 --- a/app/hooks/useReadings.ts +++ b/app/hooks/useReadings.ts @@ -4,16 +4,19 @@ import type { UseQueryResult } from '@tanstack/react-query'; import cachedFetch from 'utils/cachedFetch'; import type { ReadingsResponse } from 'data/contracts'; import { queryKeys } from 'data/queryKeys'; +import { getCalendarQueryPolicy } from 'data/calendarQueryPolicy'; export async function fetchReadings(date: string): Promise { return cachedFetch(`${process.env.API_HOST}/readings/${date}`); } -const useReadings = (date: string): UseQueryResult => +const useReadings = (date: string, enabled = true): UseQueryResult => useQuery({ queryKey: queryKeys.readings(date), queryFn: async () => fetchReadings(date), retry: false, + ...getCalendarQueryPolicy(date), + enabled, }); export default useReadings; diff --git a/app/hooks/useUpdateTOC.ts b/app/hooks/useUpdateTOC.ts deleted file mode 100644 index ef094f4f13..0000000000 --- a/app/hooks/useUpdateTOC.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect } from 'react'; -import { useSetRecoilState } from 'recoil'; - -import TOCState from 'state/TOCState'; - -const POLL_INTERVAL_MS = 500; -const MAX_FOLLOW_UPDATES = 10; - -export const useUpdateTOC = (): void => { - const setTOC = useSetRecoilState(TOCState); - - useEffect(() => { - let cancelled = false; - let timeoutId: number | undefined; - let tocLength = 0; - let updatesCount = 0; - let pageHeight = 0; - - const updateTOC = () => { - if (cancelled) { - return; - } - - const values = Object.values(window.TOC || {}); - if (pageHeight < document.body.scrollHeight || values.length !== tocLength) { - pageHeight = document.body.scrollHeight; - const sortedTOC = values - .map((item) => ({ - item, - offsetTop: document.getElementById(item.value)?.offsetTop ?? Number.POSITIVE_INFINITY, - })) - .sort((a, b) => a.offsetTop - b.offsetTop); - - setTOC(sortedTOC.map(({ item }) => item)); - tocLength = values.length; - } - - // Keep on re-running 10 times every half a second, to make sure no updates are skipped. - if (updatesCount < MAX_FOLLOW_UPDATES) { - timeoutId = window.setTimeout(updateTOC, POLL_INTERVAL_MS); - updatesCount += 1; - } - }; - - timeoutId = window.setTimeout(updateTOC, POLL_INTERVAL_MS); - - return () => { - cancelled = true; - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); - } - }; - }, [setTOC]); -}; diff --git a/app/routeLoaders.ts b/app/routeLoaders.ts new file mode 100644 index 0000000000..a80d00708f --- /dev/null +++ b/app/routeLoaders.ts @@ -0,0 +1,6 @@ +export const loadServiceRoute = async () => + await import(/* webpackChunkName: "route-service" */ 'containers/Service/Service'); + +export const preloadServiceRoute = () => { + void loadServiceRoute(); +}; diff --git a/app/state/TOCState.ts b/app/state/TOCState.ts deleted file mode 100644 index fb8801f124..0000000000 --- a/app/state/TOCState.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { atom } from 'recoil'; - -export interface TOCItem { - value: string; - label: string; - shortLabel: string; - level?: number; -} - -export type TOCRegistry = Record; - -declare global { - interface Window { - TOC?: TOCRegistry; - } -} - -const TOCState = atom({ - key: 'TOC', - default: [], -}); - -export default TOCState; diff --git a/app/utils/performanceTelemetry.ts b/app/utils/performanceTelemetry.ts new file mode 100644 index 0000000000..b0f0a1b3be --- /dev/null +++ b/app/utils/performanceTelemetry.ts @@ -0,0 +1,123 @@ +import type { Metric } from 'web-vitals'; + +interface PerformanceConnection { + effectiveType?: string; + saveData?: boolean; +} + +interface PerformanceNavigator extends Navigator { + connection?: PerformanceConnection; + deviceMemory?: number; +} + +interface ServiceMilestones { + complete?: number; + renderKey?: string; + tocReady?: number; +} + +const pushPerformanceEvent = (event: Record) => { + const telemetryWindow = window as unknown as { + dataLayer?: Array>; + }; + const standalone = + window.matchMedia('(display-mode: standalone)').matches || + Boolean((navigator as Navigator & { standalone?: boolean }).standalone); + telemetryWindow.dataLayer = telemetryWindow.dataLayer || []; + telemetryWindow.dataLayer.push({ + appMode: window.origin.includes('capacitor://') ? 'capacitor' : standalone ? 'standalone' : 'browser', + release: VERSION, + deviceMemory: (navigator as PerformanceNavigator).deviceMemory, + effectiveConnectionType: (navigator as PerformanceNavigator).connection?.effectiveType, + hardwareConcurrency: navigator.hardwareConcurrency, + saveData: (navigator as PerformanceNavigator).connection?.saveData, + serviceWorkerControlled: Boolean(navigator.serviceWorker?.controller), + standalone, + ...event, + }); +}; + +const observeServiceMilestones = () => { + if (typeof PerformanceObserver === 'undefined') { + return; + } + + const milestones: ServiceMilestones = {}; + let settledTimer: number | undefined; + + const scheduleSettledReport = () => { + if (milestones.complete === undefined || milestones.tocReady === undefined) { + return; + } + + window.clearTimeout(settledTimer); + settledTimer = window.setTimeout(() => { + const settled = performance.now(); + performance.clearMarks?.('service_settled'); + performance.mark?.('service_settled'); + pushPerformanceEvent({ + event: 'service_performance', + completeCommit: milestones.complete, + renderKey: milestones.renderKey, + settled, + tocReady: milestones.tocReady, + }); + milestones.complete = undefined; + milestones.renderKey = undefined; + milestones.tocReady = undefined; + }, 750); + }; + + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.name === 'service_complete_commit') { + milestones.complete = entry.startTime; + const detail = (entry as PerformanceMark).detail as { renderKey?: unknown } | null; + milestones.renderKey = typeof detail?.renderKey === 'string' ? detail.renderKey : undefined; + scheduleSettledReport(); + } else if (entry.name === 'service_toc_ready') { + milestones.tocReady = entry.startTime; + scheduleSettledReport(); + } + } + }); + + try { + observer.observe({ entryTypes: ['mark'] }); + } catch { + observer.disconnect(); + } +}; + +export const startPerformanceTelemetry = () => { + observeServiceMilestones(); + + const loadWebVitals = () => { + void import(/* webpackChunkName: "performance-telemetry" */ 'web-vitals') + .then(({ onCLS, onINP, onLCP, onTTFB }) => { + const report = ({ delta, id, name, rating, value }: Metric) => { + pushPerformanceEvent({ + event: 'web_vital', + metricDelta: delta, + metricId: id, + metricName: name, + metricRating: rating, + metricValue: value, + }); + }; + + onCLS(report); + onINP(report); + onLCP(report); + onTTFB(report); + }) + .catch(() => undefined); + }; + + const scheduleWebVitals = () => window.setTimeout(loadWebVitals, 4000); + if (document.readyState === 'complete') { + scheduleWebVitals(); + } else { + window.addEventListener('load', scheduleWebVitals, { once: true }); + } +}; diff --git a/app/utils/staticDelivery.mjs b/app/utils/staticDelivery.mjs new file mode 100644 index 0000000000..413c9b9d0b --- /dev/null +++ b/app/utils/staticDelivery.mjs @@ -0,0 +1,32 @@ +export const IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable'; +export const REVALIDATE_CACHE_CONTROL = 'no-cache'; + +const CONTENT_HASH = /(?:^|\.)([a-f0-9]{16,})(?:\.|$)/iu; +const COMPRESSIBLE_EXTENSIONS = new Set([ + '.css', + '.html', + '.js', + '.json', + '.map', + '.svg', + '.txt', + '.webmanifest', + '.xml', +]); + +export const normalizeStaticPath = (filePath) => filePath.replaceAll('\\', '/').replace(/^\/+/u, ''); + +export const isImmutableBuiltAsset = (filePath) => { + const normalizedPath = normalizeStaticPath(filePath); + const fileName = normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1); + return normalizedPath.startsWith('built/') && CONTENT_HASH.test(fileName); +}; + +export const isCompressibleStaticAsset = (filePath) => { + const normalizedPath = normalizeStaticPath(filePath); + const extensionStart = normalizedPath.lastIndexOf('.'); + return extensionStart >= 0 && COMPRESSIBLE_EXTENSIONS.has(normalizedPath.slice(extensionStart).toLowerCase()); +}; + +export const cacheControlForStaticPath = (filePath) => + isImmutableBuiltAsset(filePath) ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL; diff --git a/app/utils/staticDelivery.test.mjs b/app/utils/staticDelivery.test.mjs new file mode 100644 index 0000000000..33f442a2c8 --- /dev/null +++ b/app/utils/staticDelivery.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + IMMUTABLE_CACHE_CONTROL, + REVALIDATE_CACHE_CONTROL, + cacheControlForStaticPath, + isCompressibleStaticAsset, + isImmutableBuiltAsset, + normalizeStaticPath, +} from './staticDelivery.mjs'; +import { createDeploymentPlan } from '../../scripts/deploy-static.mjs'; + +const createPlan = (versionFiles = ['version', 'version.json']) => + createDeploymentPlan({ + deploymentBucket: 's3://example-bucket', + immutableRoot: '/tmp/deploy/immutable', + revalidateRoot: '/tmp/deploy/revalidate', + sourceRoot: '/repo/www', + versionFiles, + }); + +test('recognises content-hashed build assets without treating mutable build metadata as immutable', () => { + assert.equal(isImmutableBuiltAsset('built/vendor.807c1869148f94d91589.js'), true); + assert.equal(isImmutableBuiltAsset('built/605d5b6a7bdf8b7ddc75.woff2'), true); + assert.equal(isImmutableBuiltAsset('built/7521.0740774f0703f0663272.js.map'), true); + assert.equal(isImmutableBuiltAsset('built/version.json'), false); + assert.equal(isImmutableBuiltAsset('service-worker.js'), false); + assert.equal(isImmutableBuiltAsset('assets/icons/ascension.svg'), false); +}); + +test('assigns immutable caching only to content-addressed build output', () => { + assert.equal(cacheControlForStaticPath('built/main.a5c5bec4e24084239c5d.js'), IMMUTABLE_CACHE_CONTROL); + assert.equal(cacheControlForStaticPath('built/version.json'), REVALIDATE_CACHE_CONTROL); + assert.equal(cacheControlForStaticPath('index.html'), REVALIDATE_CACHE_CONTROL); + assert.equal(cacheControlForStaticPath('service-worker.js'), REVALIDATE_CACHE_CONTROL); +}); + +test('selects text-based assets for transport compression and normalizes platform paths', () => { + assert.equal(isCompressibleStaticAsset('built/main.js'), true); + assert.equal(isCompressibleStaticAsset('index.html'), true); + assert.equal(isCompressibleStaticAsset('image.png'), false); + assert.equal(isCompressibleStaticAsset('font.woff2'), false); + assert.equal(normalizeStaticPath('built\\nested\\main.js'), 'built/nested/main.js'); + assert.equal(normalizeStaticPath('/built/main.js'), 'built/main.js'); +}); + +test('uploads every immutable representation before mutable rollout references', () => { + const plan = createPlan(); + + assert.deepEqual( + plan.map(({ phase }) => phase), + [ + 'immutable-uncompressed', + 'immutable-gzip', + 'mutable-shell', + 'mutable-version:version', + 'mutable-version:version.json', + 'mutable-gzip', + ] + ); + + assert.deepEqual(plan[0].commandArguments.slice(0, 3), ['sync', '/repo/www/built/', 's3://example-bucket/built/']); + assert.ok(plan[0].commandArguments.includes('--exclude=version')); + assert.ok(plan[0].commandArguments.includes('--exclude=version.json')); + assert.deepEqual(plan[1].commandArguments.slice(0, 3), ['sync', '/tmp/deploy/immutable/', 's3://example-bucket/']); + assert.ok(plan[1].commandArguments.includes('--add-header=Content-Encoding:gzip')); + + const firstMutableStep = plan.findIndex(({ phase }) => phase.startsWith('mutable-')); + const lastImmutableStep = plan.findLastIndex(({ phase }) => phase.startsWith('immutable-')); + assert.ok(lastImmutableStep < firstMutableStep); +}); + +test('retains old content hashes and includes only version files present in the build', () => { + const plan = createPlan(['version.json']); + + assert.deepEqual( + plan.filter(({ phase }) => phase.startsWith('mutable-version:')).map(({ phase }) => phase), + ['mutable-version:version.json'] + ); + assert.equal( + plan.some(({ commandArguments }) => commandArguments.some((argument) => argument.startsWith('--delete'))), + false + ); +}); diff --git a/docs/modernization/02-performance.md b/docs/modernization/02-performance.md index 4dc138f25e..8cd6e329b1 100644 --- a/docs/modernization/02-performance.md +++ b/docs/modernization/02-performance.md @@ -1,8 +1,8 @@ # Plan 02 — Frontend Performance -Status: In progress +Status: Implemented; production deployment validation pending -Scope: Browser-side performance without backend or offline implementation changes +Scope: Browser runtime, static delivery, and offline-safe chunking. Backend, Capacitor, TWA, and service-worker behavior remain unchanged. ## Objective @@ -10,13 +10,24 @@ Improve startup time, interaction responsiveness, rerender cost, and long-term c ## Tracker -| ID | Unit | Status | Offline-sensitive | -| --- | --- | --- | --- | -| PERF-001 | Create the performance baseline | `in-progress` | No | -| PERF-002 | Lazy-load non-core routes | `done` | Yes | -| PERF-003 | Fix measured render and lifecycle waste | `done` | No | -| PERF-004 | Reduce startup and compatibility cost | `done` | Yes | -| PERF-005 | Optimize static assets | `done` | No | +| ID | Unit | Status | Offline-sensitive | +| -------- | ----------------------------------------------------------- | ----------- | ----------------- | +| PERF-001 | Create the performance baseline | `done` | No | +| PERF-002 | Lazy-load non-core routes | `done` | Yes | +| PERF-003 | Fix measured render and lifecycle waste | `done` | No | +| PERF-004 | Reduce startup and compatibility cost | `done` | Yes | +| PERF-005 | Optimize static assets | `done` | No | +| PERF-006 | Finish the reproducible service benchmark and budgets | `done` | No | +| PERF-007 | Enable compressed immutable delivery for hashed assets | `done` | No | +| PERF-008 | Remove avoidable service-entry and inactive-feature work | `done` | No | +| PERF-009 | Stabilize MDX import caching and failure handling | `done` | No | +| PERF-010 | Remove repeated per-fragment effects and subscriptions | `done` | No | +| PERF-011 | Replace TOC polling with batched incremental updates | `done` | No | +| PERF-012 | Reduce data-query fan-out with explicit freshness rules | `done` | No | +| PERF-013 | Split optional service features and inspect vendor grouping | `done` | Yes | +| PERF-014 | Generate measured coherent MDX chunk groups | `done` | Yes | +| PERF-015 | Evaluate progressive parallel/below-fold rendering | `cancelled` | Yes | +| PERF-016 | Add field performance telemetry | `done` | No | ## PERF-001 Create the performance baseline @@ -44,6 +55,10 @@ Acceptance criteria: - Baseline artifacts identify the largest entrypoint contributors. - CI displays a before/after size delta. +The long-service measurement methodology, results, budgets, bottleneck map, and +follow-up units are recorded in +[the full-service performance study](./service-performance-study.md). + ## PERF-002 Lazy-load non-core routes Objective: keep the calendar and prayer-reading path in the initial shell while deferring features that are not needed at startup. @@ -141,8 +156,46 @@ Acceptance criteria: ## Completion notes -| Date | ID | Baseline | Result | Pull request / commit | -| --- | --- | --- | --- | --- | -| 2026-07-28 | PERF-001 | 543,191 B initial JS gzip | Reproducible report added; runtime/mobile traces remain | This branch | -| 2026-07-28 | PERF-002–004 | 1,999,966 B raw / 543,191 B gzip | 1,673,473 B raw / 483,303 B gzip; offline smoke passes | This branch | -| 2026-07-28 | PERF-005 | 17 audited assets | 88,026 raw asset bytes removed with visual validation | This branch | +| Date | ID | Baseline | Result | Pull request / commit | +| ---------- | ------------ | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------- | --------------------- | +| 2026-07-28 | PERF-001 | 543,191 B initial JS gzip | Reproducible report added; runtime/mobile traces remain | This branch | +| 2026-07-28 | PERF-002–004 | 1,999,966 B raw / 543,191 B gzip | 1,673,473 B raw / 483,303 B gzip; offline smoke passes | This branch | +| 2026-07-28 | PERF-005 | 17 audited assets | 88,026 raw asset bytes removed with visual validation | This branch | +| 2026-07-28 | PERF-006 | `origin/master`: 14.09 s complete liturgy | Current: 12.31 s; full protocol and interaction cases remain | This branch | +| 2026-07-28 | PERF-006–016 | Pre-implementation current: 12.31 s uncompressed complete liturgy | Production-like gzip: 4.16 s complete / 5.72 s settled; 67-heading shape preserved | This branch | + +## Implementation result + +The completed work is detailed in +[the full-service performance study](./service-performance-study.md) and its +[compact implementation summary](./service-performance-implementation-summary.json). + +The primary three-run older-phone comparison, with the same 4× CPU and +1.6 Mbps profile but production-like gzip delivery, measured: + +- 6.02 s → 4.16 s complete service commit after coherent liturgy chunking; +- 7.58 s → 5.72 s settled; +- 605,386 B → 543,712 B JavaScript transferred; +- 123 ms → 110 ms Total Blocking Time; +- identical 67-entry TOC order, IDs, labels, document height, and paragraph count. + +The new initial entrypoint is 1,512,607 B raw / 437,057 B gzip. Workbox +precaches 1,555 URLs totalling 7.66 MB, down from 1,874 URLs / 7.77 MB before +the coherent liturgy grouping. + +Installed/offline browser QA covers an unvisited lazy route, optional search +controls, the full Zlatoust liturgy, Church Slavonic, and parallel mode with no +failed content-hashed chunk requests. + +Progressive below-fold or parallel rendering was not retained. The measured +parallel transition already reaches its complete commit in 122 ms and TOC +readiness in 848 ms on the primary older-phone profile. Deferring part of the +DOM would move work beyond the completion marker and create avoidable +find-in-page, print, anchor, and layout risks without a demonstrated need. + +Remaining deployment checks: + +- verify gzip or Brotli plus immutable headers on the real hosting path; +- verify field `web_vital` and `service_performance` events after release; +- compare production HTTP/2 field data with the deliberately conservative + HTTP/1.1 lab server. diff --git a/docs/modernization/04-code-quality-and-architecture.md b/docs/modernization/04-code-quality-and-architecture.md index 63929e05c0..b853dee68c 100644 --- a/docs/modernization/04-code-quality-and-architecture.md +++ b/docs/modernization/04-code-quality-and-architecture.md @@ -10,13 +10,13 @@ Make feature work safer by clarifying boundaries and reducing oversized, weakly ## Tracker -| ID | Unit | Status | Expected risk | -| --- | --- | --- | --- | -| QUAL-001 | Type the boundaries first | `done` | Medium | -| QUAL-002 | Stabilize the application shell | `done` | Medium | -| QUAL-003 | Split large modules by responsibility | `in-progress` | Medium | -| QUAL-004 | Remove accidental global state | `in-progress` | Low | -| QUAL-005 | Clarify state ownership | `done` | Medium | +| ID | Unit | Status | Expected risk | +| -------- | ------------------------------------- | ------------- | ------------- | +| QUAL-001 | Type the boundaries first | `done` | Medium | +| QUAL-002 | Stabilize the application shell | `done` | Medium | +| QUAL-003 | Split large modules by responsibility | `in-progress` | Medium | +| QUAL-004 | Remove accidental global state | `in-progress` | Low | +| QUAL-005 | Clarify state ownership | `done` | Medium | ## Target boundaries @@ -95,7 +95,7 @@ Objective: reduce hidden coupling and browser lifecycle leaks. Initial candidates: -- `window.TOC` +- `window.TOC` (`done` through PERF-011) - `window.pullDownDisabled` - custom history events and scroll globals - direct `window.matchMedia` listeners @@ -143,8 +143,8 @@ Acceptance criteria: ## Completion notes -| Date | ID | Extracted/changed boundary | Result | Pull request / commit | -| --- | --- | --- | --- | --- | -| 2026-07-28 | QUAL-001/002 | Typed API/query boundaries and named provider/error shell | Tests, quality, and browser smoke pass | This branch | -| 2026-07-28 | QUAL-003 | `getDayInfo.ts` split into calendar, fasting, feast/liturgy, and daily-selection modules | 16 calendar characterizations pass | This branch | -| 2026-07-28 | QUAL-004/005 | TOC lifecycle/global contract stabilized and state ownership documented | Remaining globals stay tracked | This branch | +| Date | ID | Extracted/changed boundary | Result | Pull request / commit | +| ---------- | ------------ | ---------------------------------------------------------------------------------------- | -------------------------------------- | --------------------- | +| 2026-07-28 | QUAL-001/002 | Typed API/query boundaries and named provider/error shell | Tests, quality, and browser smoke pass | This branch | +| 2026-07-28 | QUAL-003 | `getDayInfo.ts` split into calendar, fasting, feast/liturgy, and daily-selection modules | 16 calendar characterizations pass | This branch | +| 2026-07-28 | QUAL-004/005 | TOC lifecycle/global contract stabilized and state ownership documented | Remaining globals stay tracked | This branch | diff --git a/docs/modernization/06-older-device-and-perceived-performance.md b/docs/modernization/06-older-device-and-perceived-performance.md new file mode 100644 index 0000000000..700e1a4d8a --- /dev/null +++ b/docs/modernization/06-older-device-and-perceived-performance.md @@ -0,0 +1,685 @@ +# Plan 06 — Older-device and perceived performance + +Last updated: 2026-08-05 + +Overall status: `ready` for measurement; implementation has not started + +Recommended first unit: `PERF-017` + +Scope: returning-user startup, long-service rendering, touch responsiveness, +route and language transitions, scrolling, memory retention, and perceived +readiness. PHP/backend work, service-worker strategy changes, Capacitor/TWA +upgrades, and product redesign are excluded. + +Related evidence: + +- [Frontend performance plan](./02-performance.md) +- [Full-service performance study](./service-performance-study.md) +- [Post-implementation measurements](./service-performance-implementation-summary.json) + +## Objective + +Make the application feel immediate and stable on older phones, especially for +returning and installed users, without trading away offline availability, +complete service content, search, print, anchors, or accessibility. + +Developer experience is also a constraint. Performance work must not make +ordinary component authoring, debugging, testing, or maintenance materially +harder merely to reduce bundle size or satisfy a synthetic metric. A change that +degrades DX requires trace-level proof that the affected code is a critical +user-visible bottleneck, a conclusive repeated-run improvement, and evidence +that a lower-cost alternative cannot deliver the same result. + +This phase optimizes four kinds of user experience: + +1. **Readiness:** how quickly useful calendar or service content appears. +2. **Response:** how quickly a tap produces visible feedback. +3. **Continuity:** whether scrolling and transitions maintain consistent frame pacing. +4. **Durability:** whether repeated navigation remains responsive without memory, + listener, observer, or cache growth. + +## Current measured baseline + +These values are comparison anchors, not permanent budgets. Each experiment +must reproduce its own before/after baseline from the same revision, machine, +browser, profile, and deterministic content fixture. + +| Area | Current observation | +| --------------------------------- | ----------------------------------------------: | +| Initial JavaScript | 1,512,607 B raw / 437,057 B gzip | +| Initial vendor JavaScript | approximately 1,195,000 B raw / 339,000 B gzip | +| Older-phone cold complete Liturgy | 4.16 s | +| Older-phone cold Liturgy settled | 5.72 s | +| Warm SPA service entry at 6× CPU | 486.9 ms | +| Hot service revisit at 6× CPU | 202.5 ms | +| Throttled first SPA service entry | approximately 1.20 s | +| Throttled hot service revisit | approximately 155 ms | +| Russian service shape | 67 TOC entries, 568 paragraphs, 2,274 DOM nodes | +| Parallel service shape | approximately 4,050 DOM nodes | +| Hot parallel commit | approximately 109–122 ms | +| Parallel longest task | approximately 185 ms | +| TOC selection | approximately 147–175 ms | +| First search-panel open | approximately 469 ms | +| Search result | approximately 244 ms, including 180 ms debounce | +| Workbox precache | 1,555 URLs / 7.66 MB | + +The current `service_toc_ready` and `service_settled` values include deliberate +500 ms TOC and stability/quiet windows. Agents must not treat those windows as +CPU cost or move the existing marks earlier to claim an improvement. + +## Status and coordination protocol + +The tracker in this file is the source of truth for this workstream. Update it +before editing implementation files and again before handing work to another +agent. + +Status values use the definitions from the +[master roadmap](./README.md#status-values). + +### Claiming a unit + +1. Confirm all dependencies are `done` or explicitly waived in the notes. +2. Change the unit to `in-progress` in both trackers. +3. Record the owner/agent, date, branch or worktree, baseline artifact, and files + likely to change in the coordination ledger. +4. Check the conflict group. Two agents must not edit the same conflict group + concurrently in a shared worktree. +5. If agents share a worktree, parallelize only read-only experiments or units + with disjoint file ownership. + +### Completing or handing off a unit + +Record: + +- exact benchmark command and environment; +- raw result artifact path; +- median and p75 before/after values; +- content, offline, interaction, memory, and quality checks run; +- keep/revert decision and its evidence; +- remaining risk, blocker, or next action; +- commit or pull-request reference when one exists. + +An experiment that fails its retention gate should normally be reverted and +marked `cancelled`, with the useful evidence preserved in the notes. + +## Global measurement profiles + +### Installed returning-user startup + +Measure each of these separately: + +- process-cold, cache-warm, online launch; +- process-cold, installed and fully offline launch; +- warm-process launch or reload; +- anonymous user; +- authenticated user with realistic settings and habit data; +- normal, 100 KB, and 1 MB persisted-state fixtures. + +The installed profile must first complete service-worker installation and API +data precaching, close the browser process, and relaunch from the manifest start +URL. A normal page reload is not a process-cold startup measurement. + +### Long-service profiles + +- Cold direct Russian service. +- Warm calendar-to-service navigation. +- Hot revisit. +- Russian to Church Slavonic with the target language cold. +- Russian to parallel with Church Slavonic cold. +- Hot Russian, Church Slavonic, and parallel switches. +- Direct persisted-parallel launch. + +### Real interaction profiles + +- Real CDP touch swipes, not only `scrollBy`. +- Rapid vertical reading scroll and slow precision scroll. +- Date swipe in both directions. +- Menu, calendar, TOC, language, zoom, search, and back navigation. +- Twenty-to-thirty date/service/language cycles with forced-GC diagnostics when + the browser supports them. +- At least one physical low-end Android or WebView validation before release. + +## Metrics + +### Readiness marks + +- `bundle_evaluated` +- `react_render_requested` +- `inline_loader_hidden` +- `app_header_ready` +- `date_primary_content_ready` +- `above_fold_stable` +- `navigation_intent` +- `route_chunk_loaded` +- `service_shell_ready` +- `service_complete_commit` +- `service_toc_ready` +- `service_settled` +- `background_precache_started` + +Every transition mark must include a navigation/render key so SPA timestamps can +be converted to click-to-result durations. Absolute page-lifetime timestamps are +not transition durations. + +### Main-thread and interaction metrics + +- Event Timing/INP for the initiating control. +- Tap or key event to next visible paint. +- Total Blocking Time and longest task before useful content. +- Long Animation Frames when available, with long-task fallback. +- Frame intervals, frames over 33 ms and 50 ms, and worst frame. +- Script, style, layout, paint, and raster duration. +- Count and duration of non-passive touch handlers. +- React commits and component render time in a production profiling build. + +### Resource and runtime metrics + +- Initial raw, gzip, decoded, and executed JavaScript. +- JavaScript coverage at primary-content and full-service commit. +- Initial script and Workbox manifest entry counts. +- Foreground and background request counts before useful content. +- IndexedDB operations and cache writes before useful content. +- Mounted calendar slides and DOM nodes. +- Peak and retained heap, listeners, observers, and detached nodes. +- Third-party script and worker task time. + +## Global retention gates + +A candidate is retained only if it improves its intended older-device metric by +at least **10% or 100 ms**, unless the unit defines a stronger gate. + +It must also satisfy all applicable safeguards: + +- no more than 5% p75 regression in unrelated readiness or interaction metrics; +- no more than 10% Total Blocking Time regression; +- immediate visible control feedback below 100 ms ideally and below 200 ms as a + hard lab ceiling; +- stress-profile longest task at or below 200 ms; +- fewer than 5% of stress scroll frames over 50 ms; +- CLS at or below 0.1 with no visible scroll jump; +- no more than 10% peak or retained-heap regression; +- no linear listener, observer, detached-node, or heap growth over repeated use; +- initial JavaScript does not exceed 437,057 B gzip without explicit review; +- exact normalized service text, heading order, IDs, labels, and search results; +- complete print, copy/select-all, native-find, TOC, anchor, zoom, theme, editor, + audio, and scroll-restoration behavior; +- fresh install followed by fully offline direct loading of an unvisited Russian, + Church Slavonic, and parallel service; +- zero failed content-hashed offline requests; +- complete Workbox coverage and atomic update behavior; +- no material regression in component authoring, debugging, testability, or + maintainability without the stronger critical-bottleneck evidence described + above. + +## Work-unit tracker + +| ID | Priority | Work unit | Status | Risk | Depends on | Conflict group | +| -------- | -------- | ------------------------------------------------------------ | ---------- | ------------------------- | ---------------------------- | ------------------ | +| PERF-017 | P0 | Installed-startup and real-touch measurement foundation | `ready` | Low | PERF-016 | measurement | +| PERF-018 | P0 | Defer future-date precaching and cache refresh contention | `proposed` | Medium, offline-sensitive | PERF-017 | startup-client | +| PERF-019 | P0 | Defer analytics, tracing, Webvisor, and optional polyfills | `proposed` | Medium | PERF-017 | startup-client | +| PERF-020 | P0 | Replace the global pull-to-refresh touch path | `proposed` | Medium | PERF-017 | interaction-shell | +| PERF-021 | P0 | Render one calendar slide during startup | `proposed` | Medium | PERF-017 | calendar-runtime | +| PERF-022 | P1 | Split below-fold calendar and optional home features | `proposed` | Medium, offline-sensitive | PERF-021 | calendar-runtime | +| PERF-023 | P1 | Remove avoidable UI libraries from the initial shell | `proposed` | Medium | PERF-017 | startup-shell | +| PERF-024 | P1 | Defer optional auth, Convex, and native-platform code | `proposed` | High | PERF-017, PERF-023 | startup-providers | +| PERF-025 | P1 | Cache persisted state parsing and delay data revalidation | `proposed` | Medium, offline-sensitive | PERF-017 | state-and-data | +| PERF-026 | P1 | Add responsive route/language transitions and intent loading | `proposed` | Medium, offline-sensitive | PERF-017 | service-navigation | +| PERF-027 | P1 | Remove remaining per-fragment MDX reader overhead | `proposed` | Medium | PERF-017 | service-runtime | +| PERF-028 | P2 | Pilot coarse service `content-visibility` | `proposed` | High | PERF-027 | service-runtime | +| PERF-029 | P2 | Refine service-resolved MDX loading packs | `proposed` | High, offline-sensitive | PERF-017, PERF-027 | mdx-build | +| PERF-030 | P3 | Prototype a compact static-reader content representation | `deferred` | High, offline-sensitive | PERF-029 | mdx-build | +| PERF-031 | P1 | Reduce first-open menu and search latency | `proposed` | Low, offline-sensitive | PERF-017 | service-navigation | +| PERF-032 | P2 | Evaluate replacing swipeable-views gesture handling | `deferred` | High | PERF-017, PERF-020, PERF-021 | calendar-runtime | +| PERF-033 | P1 | Physical-device and long-session retention validation | `proposed` | Low | PERF-017 | validation | + +## Execution waves + +### Wave A — Establish the missing evidence + +1. PERF-017 + +Exit: process-cold installed startup, real-touch scrolling, first-versus-warm +interactions, cold language/parallel transitions, and longer retention runs all +produce machine-readable artifacts. + +### Wave B — Remove critical-window contention + +1. PERF-018 +2. PERF-019 +3. PERF-020 +4. PERF-021 + +PERF-018 and PERF-019 share `client.tsx` and must not be implemented concurrently +in the same worktree. Their A/B measurements may be prepared independently. + +Exit: useful calendar content is no longer competing with optional background +work; real finger scrolling does not depend on a permanent global non-passive +listener; only the active date view mounts during startup. + +### Wave C — Reduce startup code and render work + +1. PERF-022 +2. PERF-023 +3. PERF-024 +4. PERF-025 + +Exit: initial gzip and executed JavaScript are materially lower, anonymous and +authenticated startup both pass, and cached data remains immediately available +online and offline. + +### Wave D — Improve perceived transitions + +1. PERF-026 +2. PERF-031 + +Exit: every route, menu, search, and language action produces visible feedback +within 100 ms where possible, without showing stale content as current. + +### Wave E — Reduce long-service CPU, layout, and paint + +1. PERF-027 +2. PERF-028 +3. PERF-029 + +PERF-028 and PERF-029 are separate experiments. Do not combine content +containment with another MDX chunking change; each needs an attributable result. + +Exit: the retained approach materially improves full-service or parallel-mode +work while preserving the complete-document contract. + +### Wave F — Structural decisions and release proof + +1. PERF-030 only if the preceding results leave a demonstrated parse/evaluate or + React-fiber bottleneck. +2. PERF-032 only if real-touch measurements still show swipe-handler cost. +3. PERF-033 after each retained wave and before release. + +## Detailed work units + +### PERF-017 — Installed-startup and real-touch measurement foundation + +Objective: measure the experiences that the existing service benchmark does not +cover. + +Work: + +- Add all installed-startup, service-transition, touch, cold-language, and + retention profiles described above. +- Add keyed application marks without moving existing completion semantics. +- Capture production-parity runs with analytics and background precaching enabled. +- Add isolated controls that disable one subsystem at a time. +- Store raw artifacts plus a compact comparison summary. + +Acceptance: + +- Three-run smoke and twenty-run comparison modes are reproducible. +- Process-cold and warm-process launches are demonstrably different scenarios. +- Real touch input is visible in Event Timing and trace output. +- A failing content, offline, or interaction gate exits non-zero. + +Coordination: claim `scripts/performance/`, telemetry files, and relevant E2E +fixtures. Avoid product changes in this unit. + +Execution notes: not started. + +### PERF-018 — Defer future-date precaching and cache refresh contention + +Objective: prevent offline-maintenance work from competing with useful content. + +Hypothesis: starting the precache worker after route readiness and limiting its +concurrency will improve process-cold and cached startup without reducing the +future-day offline horizon. + +Work: + +- Compare immediate start, route-ready start, and idle start with a bounded + fallback timeout. +- Persist the last successful refresh time. +- Cap future-date work to a measured concurrency. +- Deduplicate foreground and background requests. +- Schedule cache-hit revalidation after useful content where freshness permits. + +Retain when: startup or service readiness improves by the global threshold and +the complete future-date offline corpus is available after the bounded refresh. + +Coordination: likely touches `client.tsx`, `precache.ts`, the worker, and cached +fetch scheduling. Do not overlap PERF-019 in a shared worktree. + +Execution notes: not started. + +### PERF-019 — Defer analytics, tracing, Webvisor, and optional polyfills + +Objective: keep non-product JavaScript out of the first useful render. + +Work: + +- A/B Yandex, Webvisor, GTM, Sentry tracing, and the share polyfill independently. +- Preserve early errors with a small `error`/`unhandledrejection` queue. +- Initialize retained systems after useful content or during idle. +- Emit explicit initial page-view events after delayed initialization. +- Consider sampling Webvisor by device capability or session. + +Retain when: the production-parity startup profile improves materially and +monitoring/page-view correctness is demonstrated. + +Coordination: owns startup analytics in `client.tsx` and `index.html` while +active. + +Execution notes: not started. + +### PERF-020 — Replace the global pull-to-refresh touch path + +Objective: allow normal vertical reading scroll to remain compositor-driven. + +Work: + +- Establish an A/B with pull-to-refresh disabled on service pages. +- Replace the permanent global `passive: false` listener if the A/B improves + real-touch input or frame pacing. +- Recognize only a confirmed top-edge downward pull. +- Batch visual updates to animation frames and prefer transforms over height. +- Preserve refresh, cancellation, accessibility, and reduced-motion behavior. + +Retain when: real-touch Event Timing or frame pacing improves by the global +threshold with no gesture regression on browser, installed PWA, and Capacitor +smoke profiles. + +Coordination: owns the app-shell pull gesture. It must not change date-swipe +handling in this unit. + +Execution notes: not started. + +### PERF-021 — Render one calendar slide during startup + +Objective: avoid mounting previous, current, and next full day trees before the +user can interact. + +Work: + +- Compare initial swipe overscan `1/1` with an active-only first commit. +- Prefetch adjacent-day data without mounting adjacent DOM. +- Enable adjacent rendering during idle or on swipe intent. +- Record slide count, DOM nodes, queries, MDX imports, commit time, and first + swipe latency. + +Retain when: primary calendar readiness improves materially and first-swipe p75 +does not regress by more than 100 ms after intent preloading. + +Coordination: owns `Main.tsx` and calendar swipe orchestration while active. + +Execution notes: not started. + +### PERF-022 — Split below-fold calendar and optional home features + +Objective: render the useful date heading and primary day content without +evaluating below-fold MDX and secondary features. + +Candidates: + +- calendar picker; +- homepage troparions, kondaks, and generic MDX context; +- favourites, sermons, banners, and optional prompts; +- share implementation when native share is available. + +Retain when: initial executed/gzip JavaScript and primary-content readiness +improve materially, with offline first-open coverage for every new chunk. + +Coordination: begins only after PERF-021 settles the active-slide boundary. + +Execution notes: not started. + +### PERF-023 — Remove avoidable UI libraries from the initial shell + +Objective: remove libraries whose startup cost is disproportionate to their +initial-shell responsibility. + +Experiments: + +- Replace MUI `createTheme` on the core path with the application colour theme. +- Replace or defer the MUI update prompt. +- Remove `styled-components` with the pullable replacement. +- Lazy-load `react-nice-dates` with the calendar picker. +- Keep the existing select-control architecture by default. Do not replace it + with native `