diff --git a/docs/optimizing.md b/docs/optimizing.md index 92f3005..920c157 100644 --- a/docs/optimizing.md +++ b/docs/optimizing.md @@ -5,7 +5,7 @@ Topic Index: - Time to first byte - Time to first draw: [Load size](#load-size) - CLS -- Framerate: [Algorithm](#algorithm) +- Framerate: [Algorithm](#algorithm), [CSS containment](#css-containment) ## Load size @@ -23,4 +23,19 @@ Don't choose algorithm solely on the time complexity. GUI app needs smooth, not - Think in Map-Reduce framework if you don't have any idea. - On the worker thread: balance the speed and the memory usage. - Arrays are usually faster and use less memory. - - Worker is always available on our target platforms, but workers introduce latency in the starting and the communication. + - Worker is always available on our target platforms, but workers introduce latency in the starting and the communication. And, even it's available on targets, it's not always + available on user's platform, so fallback is always required. + +## CSS containment + +`contain` property is a powerful tool that hints user agents to utilise specific conditions can only be easily identified by developers. [This article from MDN can help you undetstand this property](https://developer.mozilla.org/en-US/docs/Web/Performance/How_browsers_work). + +But it comes with cost. Modern browsers are already very smart on rendering. Using `contain`, you are trading onething off for another: + +- `layout` affects the reflow. This property usually won't make large change: mainline browsers already can incrementally reflow. +- `style` affacts the style computation, is automatically enabled when using `container` property. Usually won't make large change too, unless you frequently change the styles (and/or your stylesheet is large and/or with complex selectors). +- `paint` affects the shading, the pixel-filling process. This is useful - the shading is resource-heavy - but the browser may need more buffers and more time to compose the final frame. + - This containment may increase memory usage. +- `size` says the size is not affected by outside elements and is defined. It hints the user agent can use the pre-defined size and/or cache the computed size (with `auto` keyword). + - Must be used with `contain-intrinsic-size`. + - You can use `content-visibility: auto`, a stonger hint for browsers to skip elements if possible. You can see this like built-in "virtual list", which is used for rendering infinite size of dataset. diff --git a/src/App.tsx b/src/App.tsx index e7ca588..5e69ff3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,7 +10,7 @@ import { lazy, onCleanup, } from "solid-js"; -import { useRootTheme } from "./material/theme.js"; +import { createRootTheme } from "./material/theme.js"; import { Provider as ClientProvider, createMastoClientFor, @@ -70,7 +70,7 @@ const Routing: Component = () => { }; const App: Component = () => { - const theme = useRootTheme(); + const theme = createRootTheme(); const accts = useStore($accounts); const lang = createCurrentLanguage(); const region = createCurrentRegion(); diff --git a/src/material/Scaffold.tsx b/src/material/Scaffold.tsx index 4e905c3..a2c7b69 100644 --- a/src/material/Scaffold.tsx +++ b/src/material/Scaffold.tsx @@ -2,6 +2,7 @@ import { createElementSize } from "@solid-primitives/resize-observer"; import { JSX, Show, + children, createRenderEffect, createSignal, splitProps, @@ -21,8 +22,8 @@ type ScaffoldProps = ParentProps< /** * The passthrough props are passed to the content container. */ -const Scaffold: Component = (props) => { - const [managed, rest] = splitProps(props, [ +const Scaffold: Component = (oprops) => { + const [props, rest] = splitProps(oprops, [ "topbar", "fab", "bottom", @@ -34,9 +35,13 @@ const Scaffold: Component = (props) => { const topbarSize = createElementSize(topbarElement); + const topbar = children(() => props.topbar) + const fab = children(() => props.fab) + const bottom = children(() => props.bottom) + return (
{ createRenderEffect(() => { e.style.setProperty( @@ -45,28 +50,28 @@ const Scaffold: Component = (props) => { ); }); - if (managed.ref) { - (managed.ref as (val: typeof e) => void)(e); + if (props.ref) { + (props.ref as (val: typeof e) => void)(e); } }} {...rest} > - + - + - {managed.children} + {props.children} - +
diff --git a/src/material/theme.ts b/src/material/theme.ts index 0eb4eaf..a7a68a8 100644 --- a/src/material/theme.ts +++ b/src/material/theme.ts @@ -5,21 +5,22 @@ import { Accessor } from "solid-js"; /** * The MUI theme. */ -export function useRootTheme(): Accessor { - return () => - createTheme({ - palette: { - primary: { - main: deepPurple[500], - }, - error: { - main: red[900], - }, - secondary: { - main: amber.A200, - }, +export function createRootTheme(): Accessor { + const theme = createTheme({ + palette: { + primary: { + main: deepPurple[500], }, - }); + error: { + main: red[900], + }, + secondary: { + main: amber.A200, + }, + }, + }); + + return () => theme; } export const ANIM_CURVE_STD = "cubic-bezier(0.4, 0, 0.2, 1)"; diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx index b20106f..975d66f 100644 --- a/src/platform/StackedRouter.tsx +++ b/src/platform/StackedRouter.tsx @@ -10,6 +10,7 @@ import { Show, untrack, useContext, + onCleanup, type Accessor, } from "solid-js"; import { createStore, unwrap } from "solid-js/store"; @@ -374,6 +375,30 @@ function createManagedSwipeToBack( }; } +function animateUntil( + stepfn: (onCreated: (animation: Animation) => void) => void, +) { + const execStep = () => { + requestAnimationFrame(() => { + stepfn((step) => { + step.addEventListener("finish", () => { + execStep(); + }); + }); + }); + }; + + execStep(); +} + +/** + * The cache key of saved stack for hot reload. + * + * We could not use symbols because every time the hot reload the `Symbol()` + * call creates a new symbol. + */ +const $StackedRouterSavedStack = "$StackedRouterSavedStack"; + /** * The router that stacks the pages. * @@ -417,6 +442,29 @@ const StackedRouter: Component = (oprops) => { const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" }); const windowSize = useWindowSize(); + if (import.meta.hot) { + const saveStack = () => { + import.meta.hot!.data[$StackedRouterSavedStack] = unwrap(stack); + console.debug("stack saved"); + }; + + import.meta.hot.on("vite:beforeUpdate", saveStack); + onCleanup(() => import.meta.hot!.off("vite:beforeUpdate", saveStack)); + + const loadStack = () => { + const savedStack = import.meta.hot!.data[$StackedRouterSavedStack]; + if (savedStack) { + mutStack(savedStack); + console.debug("stack loaded"); + } + delete import.meta.hot!.data[$StackedRouterSavedStack]; + }; + + createRenderEffect(() => { + loadStack() + }); + } + const pushFrame = (path: string, opts?: Readonly>) => untrack(() => { const frame = { @@ -444,11 +492,35 @@ const StackedRouter: Component = (oprops) => { return frame; }); - const onlyPopFrame = (depth: number) => { + const onlyPopFrameOnStack = (depth: number) => { mutStack((o) => o.toSpliced(o.length - depth, depth)); + }; + + const onlyPopFrame = (depth: number) => { + onlyPopFrameOnStack(depth); window.history.go(-depth); }; + const animatePopOneFrame = (onCreated: (animation: Animation) => void) => { + const lastFrame = stack[stack.length - 1]; + const element = document.getElementById( + lastFrame.rootId, + )! as HTMLDialogElement; + const createAnimation = lastFrame.animateClose ?? animateClose; + element.classList.add("animating"); + + const onNavAnimEnd = () => { + element.classList.remove("animating"); + }; + + requestAnimationFrame(() => { + const animation = createAnimation(element); + animation.addEventListener("finish", onNavAnimEnd); + animation.addEventListener("cancel", onNavAnimEnd); + onCreated(animation); + }); + }; + const popFrame = (depth: number = 1) => untrack(() => { if (import.meta.env.DEV) { @@ -456,30 +528,27 @@ const StackedRouter: Component = (oprops) => { console.warn("the depth to pop should not < 0, now is", depth); } } + if (stack.length > 1) { - const lastFrame = stack[stack.length - 1]; - const element = document.getElementById( - lastFrame.rootId, - )! as HTMLDialogElement; - const createAnimation = lastFrame.animateClose ?? animateClose; - requestAnimationFrame(() => { - element.classList.add("animating"); - const animation = createAnimation(element); - animation.addEventListener("finish", () => { - element.classList.remove("animating"); - onlyPopFrame(depth); - }); + let count = depth; + animateUntil((created) => { + if (count > 0) { + animatePopOneFrame((a) => { + a.addEventListener("finish", () => onlyPopFrame(1)); + created(a); + }); + } + count--; }); } else { - onlyPopFrame(depth); + onlyPopFrame(1); } }); createRenderEffect(() => { if (stack.length === 0) { - mutStack(0, { - path: window.location.pathname, - rootId: createUniqueId(), + pushFrame(window.location.pathname, { + replace: "all", }); } }); @@ -488,10 +557,23 @@ const StackedRouter: Component = (oprops) => { makeEventListener(window, "popstate", (event) => { if (!event.state) return; + // TODO: verify the stack in state and handling forwards + if (stack.length === 0) { - mutStack(event.state); + mutStack(event.state || []); } else if (stack.length > event.state.length) { - popFrame(stack.length - event.state.length); + let count = stack.length - event.state.length; + animateUntil((created) => { + if (count > 0) { + animatePopOneFrame((a) => { + a.addEventListener("finish", () => { + onlyPopFrameOnStack(1); + created(a); + }); + }); + } + count--; + }); } }); }); diff --git a/src/platform/i18n.tsx b/src/platform/i18n.tsx index b64b920..69bae99 100644 --- a/src/platform/i18n.tsx +++ b/src/platform/i18n.tsx @@ -25,12 +25,22 @@ const DEFAULT_LANG = "en"; /** * Decide the using language for the user. + * + * **Performance**: This function is costy, make sure you cache the result. + * In the app, you should use {@link useAppLocale} instead. + * * @returns the selected language tag */ export function autoMatchLangTag() { return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG); } +/** + * Decide the using region for the user. + * + * **Performance**: This function is costy, make sure you cache the result. + * In the app, you should use {@link useAppLocale} instead. + */ export function autoMatchRegion() { const specifiers = navigator.languages.map((x) => x.split("-")); @@ -99,7 +109,7 @@ export function useDateFnLocale(): Accessor { export function createCurrentLanguage() { const settings = useStore($settings); - return () => settings().language || autoMatchLangTag(); + return createMemo(() => settings().language || autoMatchLangTag()); } type ImportFn = (name: string) => Promise<{ default: T }>; @@ -114,10 +124,30 @@ type MergedImportedModule = T extends [] ? ImportedModule & MergedImportedModule : never; +/** + * Create a resource that combines all I18N strings into one object. + * + * The result is combined in the order of the argument functions. + * The formers will be overrided by the latter. + * + * @param importFns a series of functions imports the string modules + * based on the specified language code. + * + * **Context**: This function must be used under {@link AppLocaleProvider}. + * + * @example ````ts + * const [strings] = createStringResource( + * async (code) => await import(`./i18n/${code}.json`), // Vite can handle the bundling + * async () => import("./i18n/generic.json"), // You can also ignore the code. + * ); + * ```` + * + * @see {@link createTranslator} if you need a Translator from "@solid-primitives/i18n" + */ export function createStringResource< T extends ImportFn | undefined>>[], >(...importFns: T) { - const language = createCurrentLanguage(); + const { language } = useAppLocale(); const cache: Record> = {}; return createResource( @@ -140,6 +170,18 @@ export function createStringResource< ); } +/** + * Create the Translator from "@solid-primitives/i18n" based on + * the {@link createStringResource}. + * + * @param importFns same to {@link createStringResource} + * + * @returns the first element is the translator, the second is the result from + * {@link createStringResource}. + * + * @see {@link translator} for the translator usage + * @see {@link createStringResource} for the raw strings + */ export function createTranslator< T extends ImportFn | undefined>>[], >(...importFns: T) { diff --git a/src/platform/share.tsx b/src/platform/share.tsx index 3f1d870..f135736 100644 --- a/src/platform/share.tsx +++ b/src/platform/share.tsx @@ -16,7 +16,7 @@ import { import { Close as CloseIcon, ContentCopy } from "@suid/icons-material"; import { Title } from "~material/typography"; import { render } from "solid-js/web"; -import { useRootTheme } from "~material/theme"; +import { createRootTheme } from "~material/theme"; const ShareBottomSheet: Component<{ data?: ShareData; @@ -78,7 +78,7 @@ export async function share(data?: ShareData): Promise { const dispose = render(() => { const [open, setOpen] = createSignal(true); - const theme = useRootTheme(); + const theme = createRootTheme(); onCleanup(() => { element.remove(); resolve(); diff --git a/src/timelines/RegularToot.css b/src/timelines/RegularToot.css index d7a50c3..e00535e 100644 --- a/src/timelines/RegularToot.css +++ b/src/timelines/RegularToot.css @@ -4,7 +4,7 @@ --toot-avatar-size: 40px; margin-block: 0; position: relative; - contain: layout style; + contain: content; cursor: pointer;