Compare commits

..

6 commits

Author SHA1 Message Date
thislight
88ff705d68
docs/optimizing: update for CSS containment
All checks were successful
/ depoly (push) Successful in 1m17s
2024-11-25 17:57:11 +08:00
thislight
fe39675d0c
StackedRouter: fixing browser backwards
* Supports hot reload
2024-11-25 17:02:02 +08:00
thislight
62a80ddce2
Scaffold: cache children with memo 2024-11-25 15:24:13 +08:00
thislight
b1812392cb
i18n: optimize performance
* createCurrentLanguage: caching result with memo
* createStringResource: use useAppLocale
2024-11-25 15:22:25 +08:00
thislight
6df5c2e216
RegularToot: set contain to content 2024-11-25 14:55:27 +08:00
thislight
62d3a5a3d0
rename useRootTheme to createRootTheme 2024-11-25 14:53:46 +08:00
8 changed files with 199 additions and 54 deletions

View file

@ -5,7 +5,7 @@ Topic Index:
- Time to first byte - Time to first byte
- Time to first draw: [Load size](#load-size) - Time to first draw: [Load size](#load-size)
- CLS - CLS
- Framerate: [Algorithm](#algorithm) - Framerate: [Algorithm](#algorithm), [CSS containment](#css-containment)
## Load size ## 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. - Think in Map-Reduce framework if you don't have any idea.
- On the worker thread: balance the speed and the memory usage. - On the worker thread: balance the speed and the memory usage.
- Arrays are usually faster and use less memory. - 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.

View file

@ -10,7 +10,7 @@ import {
lazy, lazy,
onCleanup, onCleanup,
} from "solid-js"; } from "solid-js";
import { useRootTheme } from "./material/theme.js"; import { createRootTheme } from "./material/theme.js";
import { import {
Provider as ClientProvider, Provider as ClientProvider,
createMastoClientFor, createMastoClientFor,
@ -70,7 +70,7 @@ const Routing: Component = () => {
}; };
const App: Component = () => { const App: Component = () => {
const theme = useRootTheme(); const theme = createRootTheme();
const accts = useStore($accounts); const accts = useStore($accounts);
const lang = createCurrentLanguage(); const lang = createCurrentLanguage();
const region = createCurrentRegion(); const region = createCurrentRegion();

View file

@ -2,6 +2,7 @@ import { createElementSize } from "@solid-primitives/resize-observer";
import { import {
JSX, JSX,
Show, Show,
children,
createRenderEffect, createRenderEffect,
createSignal, createSignal,
splitProps, splitProps,
@ -21,8 +22,8 @@ type ScaffoldProps = ParentProps<
/** /**
* The passthrough props are passed to the content container. * The passthrough props are passed to the content container.
*/ */
const Scaffold: Component<ScaffoldProps> = (props) => { const Scaffold: Component<ScaffoldProps> = (oprops) => {
const [managed, rest] = splitProps(props, [ const [props, rest] = splitProps(oprops, [
"topbar", "topbar",
"fab", "fab",
"bottom", "bottom",
@ -34,9 +35,13 @@ const Scaffold: Component<ScaffoldProps> = (props) => {
const topbarSize = createElementSize(topbarElement); const topbarSize = createElementSize(topbarElement);
const topbar = children(() => props.topbar)
const fab = children(() => props.fab)
const bottom = children(() => props.bottom)
return ( return (
<div <div
class={`Scaffold ${managed.class || ""}`} class={`Scaffold ${props.class || ""}`}
ref={(e) => { ref={(e) => {
createRenderEffect(() => { createRenderEffect(() => {
e.style.setProperty( e.style.setProperty(
@ -45,28 +50,28 @@ const Scaffold: Component<ScaffoldProps> = (props) => {
); );
}); });
if (managed.ref) { if (props.ref) {
(managed.ref as (val: typeof e) => void)(e); (props.ref as (val: typeof e) => void)(e);
} }
}} }}
{...rest} {...rest}
> >
<Show when={props.topbar}> <Show when={topbar()}>
<div class="topbar" ref={setTopbarElement} role="presentation"> <div class="topbar" ref={setTopbarElement} role="presentation">
{props.topbar} {topbar()}
</div> </div>
</Show> </Show>
<Show when={props.fab}> <Show when={fab()}>
<div class="fab-dock" role="presentation"> <div class="fab-dock" role="presentation">
{props.fab} {fab()}
</div> </div>
</Show> </Show>
{managed.children} {props.children}
<Show when={props.bottom}> <Show when={bottom()}>
<div class="bottom-dock" role="presentation"> <div class="bottom-dock" role="presentation">
{props.bottom} {bottom()}
</div> </div>
</Show> </Show>
</div> </div>

View file

@ -5,21 +5,22 @@ import { Accessor } from "solid-js";
/** /**
* The MUI theme. * The MUI theme.
*/ */
export function useRootTheme(): Accessor<Theme> { export function createRootTheme(): Accessor<Theme> {
return () => const theme = createTheme({
createTheme({ palette: {
palette: { primary: {
primary: { main: deepPurple[500],
main: deepPurple[500],
},
error: {
main: red[900],
},
secondary: {
main: amber.A200,
},
}, },
}); error: {
main: red[900],
},
secondary: {
main: amber.A200,
},
},
});
return () => theme;
} }
export const ANIM_CURVE_STD = "cubic-bezier(0.4, 0, 0.2, 1)"; export const ANIM_CURVE_STD = "cubic-bezier(0.4, 0, 0.2, 1)";

View file

@ -10,6 +10,7 @@ import {
Show, Show,
untrack, untrack,
useContext, useContext,
onCleanup,
type Accessor, type Accessor,
} from "solid-js"; } from "solid-js";
import { createStore, unwrap } from "solid-js/store"; 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. * The router that stacks the pages.
* *
@ -417,6 +442,29 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" }); const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
const windowSize = useWindowSize(); 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<NewFrameOptions<any>>) => const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
untrack(() => { untrack(() => {
const frame = { const frame = {
@ -444,11 +492,35 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
return frame; return frame;
}); });
const onlyPopFrame = (depth: number) => { const onlyPopFrameOnStack = (depth: number) => {
mutStack((o) => o.toSpliced(o.length - depth, depth)); mutStack((o) => o.toSpliced(o.length - depth, depth));
};
const onlyPopFrame = (depth: number) => {
onlyPopFrameOnStack(depth);
window.history.go(-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) => const popFrame = (depth: number = 1) =>
untrack(() => { untrack(() => {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@ -456,30 +528,27 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
console.warn("the depth to pop should not < 0, now is", depth); console.warn("the depth to pop should not < 0, now is", depth);
} }
} }
if (stack.length > 1) { if (stack.length > 1) {
const lastFrame = stack[stack.length - 1]; let count = depth;
const element = document.getElementById( animateUntil((created) => {
lastFrame.rootId, if (count > 0) {
)! as HTMLDialogElement; animatePopOneFrame((a) => {
const createAnimation = lastFrame.animateClose ?? animateClose; a.addEventListener("finish", () => onlyPopFrame(1));
requestAnimationFrame(() => { created(a);
element.classList.add("animating"); });
const animation = createAnimation(element); }
animation.addEventListener("finish", () => { count--;
element.classList.remove("animating");
onlyPopFrame(depth);
});
}); });
} else { } else {
onlyPopFrame(depth); onlyPopFrame(1);
} }
}); });
createRenderEffect(() => { createRenderEffect(() => {
if (stack.length === 0) { if (stack.length === 0) {
mutStack(0, { pushFrame(window.location.pathname, {
path: window.location.pathname, replace: "all",
rootId: createUniqueId(),
}); });
} }
}); });
@ -488,10 +557,23 @@ const StackedRouter: Component<StackedRouterProps> = (oprops) => {
makeEventListener(window, "popstate", (event) => { makeEventListener(window, "popstate", (event) => {
if (!event.state) return; if (!event.state) return;
// TODO: verify the stack in state and handling forwards
if (stack.length === 0) { if (stack.length === 0) {
mutStack(event.state); mutStack(event.state || []);
} else if (stack.length > event.state.length) { } 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--;
});
} }
}); });
}); });

View file

@ -25,12 +25,22 @@ const DEFAULT_LANG = "en";
/** /**
* Decide the using language for the user. * 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 * @returns the selected language tag
*/ */
export function autoMatchLangTag() { export function autoMatchLangTag() {
return match(Array.from(navigator.languages), SUPPORTED_LANGS, DEFAULT_LANG); 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() { export function autoMatchRegion() {
const specifiers = navigator.languages.map((x) => x.split("-")); const specifiers = navigator.languages.map((x) => x.split("-"));
@ -99,7 +109,7 @@ export function useDateFnLocale(): Accessor<Locale> {
export function createCurrentLanguage() { export function createCurrentLanguage() {
const settings = useStore($settings); const settings = useStore($settings);
return () => settings().language || autoMatchLangTag(); return createMemo(() => settings().language || autoMatchLangTag());
} }
type ImportFn<T> = (name: string) => Promise<{ default: T }>; type ImportFn<T> = (name: string) => Promise<{ default: T }>;
@ -114,10 +124,30 @@ type MergedImportedModule<T> = T extends []
? ImportedModule<I> & MergedImportedModule<J> ? ImportedModule<I> & MergedImportedModule<J>
: never; : 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< export function createStringResource<
T extends ImportFn<Record<string, string | Template<any> | undefined>>[], T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
>(...importFns: T) { >(...importFns: T) {
const language = createCurrentLanguage(); const { language } = useAppLocale();
const cache: Record<string, MergedImportedModule<T>> = {}; const cache: Record<string, MergedImportedModule<T>> = {};
return createResource( 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< export function createTranslator<
T extends ImportFn<Record<string, string | Template<any> | undefined>>[], T extends ImportFn<Record<string, string | Template<any> | undefined>>[],
>(...importFns: T) { >(...importFns: T) {

View file

@ -16,7 +16,7 @@ import {
import { Close as CloseIcon, ContentCopy } from "@suid/icons-material"; import { Close as CloseIcon, ContentCopy } from "@suid/icons-material";
import { Title } from "~material/typography"; import { Title } from "~material/typography";
import { render } from "solid-js/web"; import { render } from "solid-js/web";
import { useRootTheme } from "~material/theme"; import { createRootTheme } from "~material/theme";
const ShareBottomSheet: Component<{ const ShareBottomSheet: Component<{
data?: ShareData; data?: ShareData;
@ -78,7 +78,7 @@ export async function share(data?: ShareData): Promise<void> {
const dispose = render(() => { const dispose = render(() => {
const [open, setOpen] = createSignal(true); const [open, setOpen] = createSignal(true);
const theme = useRootTheme(); const theme = createRootTheme();
onCleanup(() => { onCleanup(() => {
element.remove(); element.remove();
resolve(); resolve();

View file

@ -4,7 +4,7 @@
--toot-avatar-size: 40px; --toot-avatar-size: 40px;
margin-block: 0; margin-block: 0;
position: relative; position: relative;
contain: layout style; contain: content;
cursor: pointer; cursor: pointer;