From 8902b1d30f045ccb42a7e4002bc6d3c1fd9d5766 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 10 Jun 2026 18:51:27 +0200 Subject: [PATCH 1/3] persist `show full text` state of an item to query params clicking `show full text` will save the `full` state to the query params. coming back to an item page with the `full` query param will now show the full text on first load. --- components/text.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/components/text.js b/components/text.js index 984c3300f..3f07a443a 100644 --- a/components/text.js +++ b/components/text.js @@ -23,15 +23,23 @@ export function useOverflow ({ containerRef, truncated = false }) { const router = useRouter() // would the text overflow on the current screen size? const [overflowing, setOverflowing] = useState(false) - // should we show the full text? - const [show, setShow] = useState(false) - const showOverflow = useCallback(() => setShow(true), [setShow]) + // always show the full text if the `full` query param is set + const [show, setShow] = useState(!!router.query.full) + + const showOverflow = useCallback(() => { + // save the full text state to the query param + router.replace({ + pathname: router.pathname, + query: { ...router.query, full: true } + }, router.asPath, { shallow: true }) + setShow(true) + }, [router]) // if we are navigating to a hash, show the full text useEffect(() => { - setShow(router.asPath.includes('#')) - const handleRouteChange = (url, { shallow }) => { - setShow(url.includes('#')) + if (router.asPath.includes('#')) setShow(true) + const handleRouteChange = (url) => { + if (url.includes('#')) setShow(true) } router.events.on('hashChangeStart', handleRouteChange) From ab2dd2e9f0a2be2c1cafb7547f69b5fb134324c8 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 11 Jun 2026 00:51:02 +0200 Subject: [PATCH 2/3] refactor: persist expanded item state in sessionStorage uses `useSyncExternalStore` to render the expanded state from `sessionStorage` during the first render. coming back to an item page with the `full` query param will show the full text on first load. --- components/text.js | 52 ++++++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/components/text.js b/components/text.js index 3f07a443a..6b470a985 100644 --- a/components/text.js +++ b/components/text.js @@ -1,5 +1,5 @@ import styles from './text.module.css' -import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react' +import React, { useState, useRef, useCallback, useMemo, useEffect, useSyncExternalStore } from 'react' import reactStringReplace from 'react-string-replace' import { Button } from 'react-bootstrap' import { useRouter } from 'next/router' @@ -19,27 +19,39 @@ export function SearchText ({ text }) { ) } -export function useOverflow ({ containerRef, truncated = false }) { +export function useOverflow ({ containerRef, itemId, topLevel, truncated = false }) { const router = useRouter() // would the text overflow on the current screen size? const [overflowing, setOverflowing] = useState(false) - // always show the full text if the `full` query param is set - const [show, setShow] = useState(!!router.query.full) + + // did the user expand the text (show full text button, hash navigation)? + const [expanded, setExpanded] = useState(false) + + const storageKey = itemId && `showFullText:${itemId}` + + // was the text expanded earlier in this tab session, or are we on a hash anchor? + // scroll restoration measures the first render, so we read this during it; + // subscribe is a no-op because sessionStorage doesn't emit same-tab events. + const subscribe = useCallback(() => () => {}, []) + const storedShow = useSyncExternalStore( + subscribe, + () => (!!storageKey && window.sessionStorage.getItem(storageKey) === 'true') || window.location.hash !== '', + () => false + ) + + const show = expanded || storedShow || !!(topLevel && router.query.full) const showOverflow = useCallback(() => { - // save the full text state to the query param - router.replace({ - pathname: router.pathname, - query: { ...router.query, full: true } - }, router.asPath, { shallow: true }) - setShow(true) - }, [router]) - - // if we are navigating to a hash, show the full text + // remember the expanded state for the rest of the tab session + if (storageKey) window.sessionStorage.setItem(storageKey, 'true') + setExpanded(true) + }, [storageKey]) + + // once we navigate to a hash, keep the full text shown for the rest of this mount, + // even if the hash is later removed from the URL useEffect(() => { - if (router.asPath.includes('#')) setShow(true) const handleRouteChange = (url) => { - if (url.includes('#')) setShow(true) + if (url.includes('#')) setExpanded(true) } router.events.on('hashChangeStart', handleRouteChange) @@ -47,7 +59,7 @@ export function useOverflow ({ containerRef, truncated = false }) { return () => { router.events.off('hashChangeStart', handleRouteChange) } - }, [router.asPath, router.events]) + }, [router.events]) // clip item and give it a`show full text` button if we are overflowing useEffect(() => { @@ -103,9 +115,9 @@ export function useOverflow ({ containerRef, truncated = false }) { ) } return null - }, [showOverflow, overflowing, show, setShow]) + }, [showOverflow, overflowing, show]) - return { overflowing, show, setShow, Overflow } + return { overflowing, show, setShow: setExpanded, Overflow } } /** @@ -129,9 +141,9 @@ export default function Text (props) { return } -export function TextBody ({ topLevel, children, className, innerClassName, state, html, imgproxyUrls, rel, name, readerRef }) { +export function TextBody ({ topLevel, itemId, children, className, innerClassName, state, html, imgproxyUrls, rel, name, readerRef }) { const containerRef = useRef(null) - const { overflowing, show, Overflow } = useOverflow({ containerRef, truncated: !!children }) + const { overflowing, show, Overflow } = useOverflow({ containerRef, itemId, topLevel, truncated: !!children }) const carousel = useCarousel() const textClassNames = useMemo(() => { From 3ef578290ba7fb0ac0ac829ef721802c73dad50a Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 11 Jun 2026 02:26:59 +0200 Subject: [PATCH 3/3] cleanup: useSyncExternalStore is not reactive, only used to influence first render `full` query param removed as unused --- components/text.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/components/text.js b/components/text.js index 6b470a985..17bd4fe8f 100644 --- a/components/text.js +++ b/components/text.js @@ -19,7 +19,7 @@ export function SearchText ({ text }) { ) } -export function useOverflow ({ containerRef, itemId, topLevel, truncated = false }) { +export function useOverflow ({ containerRef, itemId, truncated = false }) { const router = useRouter() // would the text overflow on the current screen size? const [overflowing, setOverflowing] = useState(false) @@ -29,17 +29,16 @@ export function useOverflow ({ containerRef, itemId, topLevel, truncated = false const storageKey = itemId && `showFullText:${itemId}` - // was the text expanded earlier in this tab session, or are we on a hash anchor? - // scroll restoration measures the first render, so we read this during it; - // subscribe is a no-op because sessionStorage doesn't emit same-tab events. - const subscribe = useCallback(() => () => {}, []) + // read-on-mount, not reactive: no same-tab storage event, so subscribe never fires. + // getSnapshot runs during render so scroll restoration sees the right height. + const subscribeNever = useCallback(() => () => {}, []) const storedShow = useSyncExternalStore( - subscribe, + subscribeNever, () => (!!storageKey && window.sessionStorage.getItem(storageKey) === 'true') || window.location.hash !== '', () => false ) - const show = expanded || storedShow || !!(topLevel && router.query.full) + const show = expanded || storedShow const showOverflow = useCallback(() => { // remember the expanded state for the rest of the tab session @@ -143,7 +142,7 @@ export default function Text (props) { export function TextBody ({ topLevel, itemId, children, className, innerClassName, state, html, imgproxyUrls, rel, name, readerRef }) { const containerRef = useRef(null) - const { overflowing, show, Overflow } = useOverflow({ containerRef, itemId, topLevel, truncated: !!children }) + const { overflowing, show, Overflow } = useOverflow({ containerRef, itemId, truncated: !!children }) const carousel = useCarousel() const textClassNames = useMemo(() => {