Skip to content

Commit 3cb6e33

Browse files
ENvironmentSetanakin_karrot
andauthored
feat(react): utilize synchronously inspectable promise to optimize suspense fallback rendering (#658)
Co-authored-by: anakin_karrot <[email protected]>
1 parent 29a0bb6 commit 3cb6e33

File tree

11 files changed

+245
-75
lines changed

11 files changed

+245
-75
lines changed

.changeset/happy-ghosts-peel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@stackflow/react": minor
3+
---
4+
5+
utilize synchronously inspectable promise to optimize suspense fallback rendering
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { StaticActivityComponentType } from "./StaticActivityComponentType";
22

33
export type LazyActivityComponentType<T extends {} = {}> =
4-
React.LazyExoticComponent<StaticActivityComponentType<T>> & {
4+
StaticActivityComponentType<T> & {
55
_load?: () => Promise<{ default: StaticActivityComponentType<T> }>;
66
};

integrations/react/src/__internal__/StructuredActivityComponentType.tsx

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@ import type {
22
InferActivityParams,
33
RegisteredActivityName,
44
} from "@stackflow/config";
5-
import { type ComponentType, lazy, type ReactNode } from "react";
5+
import type { ComponentType, ReactNode } from "react";
6+
import { preloadableLazyComponent } from "./utils/PreloadableLazyComponent";
7+
import {
8+
inspect,
9+
PromiseStatus,
10+
reject,
11+
resolve,
12+
type SyncInspectablePromise,
13+
} from "./utils/SyncInspectablePromise";
614

715
export const STRUCTURED_ACTIVITY_COMPONENT_TYPE: unique symbol = Symbol(
816
"STRUCTURED_ACTIVITY_COMPONENT_TYPE",
@@ -26,8 +34,26 @@ export function structuredActivityComponent<
2634
loading?: Loading<InferActivityParams<ActivityName>>;
2735
errorHandler?: ErrorHandler<InferActivityParams<ActivityName>>;
2836
}): StructuredActivityComponentType<InferActivityParams<ActivityName>> {
37+
const content = options.content;
38+
let cachedContent: SyncInspectablePromise<{
39+
default: Content<InferActivityParams<ActivityName>>;
40+
}> | null = null;
41+
2942
return {
3043
...options,
44+
content:
45+
typeof content !== "function"
46+
? content
47+
: () => {
48+
if (
49+
!cachedContent ||
50+
inspect(cachedContent).status === PromiseStatus.REJECTED
51+
) {
52+
cachedContent = resolve(content());
53+
}
54+
55+
return cachedContent;
56+
},
3157
[STRUCTURED_ACTIVITY_COMPONENT_TYPE]: true,
3258
};
3359
}
@@ -64,16 +90,27 @@ export function getContentComponent(
6490
return ContentComponentMap.get(structuredActivityComponent)!;
6591
}
6692

67-
const content = structuredActivityComponent.content;
68-
const ContentComponent =
69-
"component" in content
70-
? content.component
71-
: lazy(async () => {
72-
const {
73-
default: { component: Component },
74-
} = await content();
75-
return { default: Component };
76-
});
93+
const { Component: ContentComponent } = preloadableLazyComponent(() => {
94+
const content = structuredActivityComponent.content;
95+
const contentPromise = resolve(
96+
typeof content === "function" ? content() : { default: content },
97+
);
98+
const state = inspect(contentPromise);
99+
100+
if (state.status === PromiseStatus.FULFILLED) {
101+
return resolve({
102+
default: state.value.default.component,
103+
});
104+
} else if (state.status === PromiseStatus.REJECTED) {
105+
return reject(state.reason);
106+
}
107+
108+
return resolve(
109+
contentPromise.then((value) => ({
110+
default: value.default.component,
111+
})),
112+
);
113+
});
77114

78115
ContentComponentMap.set(structuredActivityComponent, ContentComponent);
79116

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { ComponentType } from "react";
2+
import {
3+
inspect,
4+
PromiseStatus,
5+
type SyncInspectablePromise,
6+
} from "./SyncInspectablePromise";
7+
import { useThenable } from "./useThenable";
8+
9+
export function preloadableLazyComponent<P extends {}>(
10+
load: () => SyncInspectablePromise<{ default: ComponentType<P> }>,
11+
): { Component: ComponentType<P>; preload: () => Promise<void> } {
12+
let cachedLoadingPromise: SyncInspectablePromise<{
13+
default: ComponentType<P>;
14+
}> | null = null;
15+
const cachedLoad = () => {
16+
if (
17+
!cachedLoadingPromise ||
18+
inspect(cachedLoadingPromise).status === PromiseStatus.REJECTED
19+
) {
20+
cachedLoadingPromise = load();
21+
}
22+
23+
return cachedLoadingPromise;
24+
};
25+
const Component = (props: P) => {
26+
const { default: Component } = useThenable(cachedLoad());
27+
28+
return <Component {...props} />;
29+
};
30+
31+
return {
32+
Component,
33+
preload: async () => void (await cachedLoad()),
34+
};
35+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { isPromiseLike } from "./isPromiseLike";
2+
3+
export interface SyncInspectablePromise<T> extends Promise<T> {
4+
status: PromiseStatus;
5+
value?: T;
6+
reason?: unknown;
7+
}
8+
9+
export const PromiseStatus = {
10+
PENDING: "pending",
11+
FULFILLED: "fulfilled",
12+
REJECTED: "rejected",
13+
} as const;
14+
export type PromiseStatus = (typeof PromiseStatus)[keyof typeof PromiseStatus];
15+
16+
export type PromiseState<T> =
17+
| {
18+
status: typeof PromiseStatus.PENDING;
19+
}
20+
| {
21+
status: typeof PromiseStatus.FULFILLED;
22+
value: T;
23+
}
24+
| {
25+
status: typeof PromiseStatus.REJECTED;
26+
reason: unknown;
27+
};
28+
29+
export function inspect<T>(
30+
promise: SyncInspectablePromise<T>,
31+
): PromiseState<T> {
32+
if (promise.status === PromiseStatus.PENDING) {
33+
return {
34+
status: PromiseStatus.PENDING,
35+
};
36+
} else if (promise.status === PromiseStatus.FULFILLED && "value" in promise) {
37+
return {
38+
status: PromiseStatus.FULFILLED,
39+
value: promise.value as T,
40+
};
41+
} else if (promise.status === PromiseStatus.REJECTED && "reason" in promise) {
42+
return {
43+
status: PromiseStatus.REJECTED,
44+
reason: promise.reason,
45+
};
46+
} else {
47+
throw new Error("Invalid promise state");
48+
}
49+
}
50+
51+
function makeSyncInspectable<T>(
52+
thenable: PromiseLike<T>,
53+
): SyncInspectablePromise<T> {
54+
const syncInspectablePromise: SyncInspectablePromise<T> = Object.assign(
55+
new Promise<T>((resolve) => resolve(thenable)),
56+
{ status: PromiseStatus.PENDING },
57+
);
58+
59+
syncInspectablePromise.then(
60+
(value) => {
61+
syncInspectablePromise.status = PromiseStatus.FULFILLED;
62+
syncInspectablePromise.value = value;
63+
},
64+
(reason) => {
65+
syncInspectablePromise.status = PromiseStatus.REJECTED;
66+
syncInspectablePromise.reason = reason;
67+
},
68+
);
69+
70+
return syncInspectablePromise;
71+
}
72+
73+
export function resolve<T>(value: T): SyncInspectablePromise<Awaited<T>> {
74+
if (isPromiseLike(value)) {
75+
if (
76+
value instanceof Promise &&
77+
"status" in value &&
78+
Object.values(PromiseStatus).some((status) => status === value.status)
79+
) {
80+
return value as SyncInspectablePromise<Awaited<T>>;
81+
}
82+
83+
return makeSyncInspectable(value) as SyncInspectablePromise<Awaited<T>>;
84+
}
85+
86+
return Object.assign(Promise.resolve(value), {
87+
status: PromiseStatus.FULFILLED,
88+
value,
89+
}) as SyncInspectablePromise<Awaited<T>>;
90+
}
91+
92+
export function reject(error: unknown): SyncInspectablePromise<never> {
93+
return Object.assign(Promise.reject(error), {
94+
status: PromiseStatus.REJECTED,
95+
reason: error,
96+
}) as SyncInspectablePromise<never>;
97+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { inspect, PromiseStatus, resolve } from "./SyncInspectablePromise";
2+
3+
export function useThenable<T>(thenable: PromiseLike<T>): Awaited<T> {
4+
const syncInspectable = resolve(thenable);
5+
const state = inspect(syncInspectable);
6+
7+
if (state.status === PromiseStatus.FULFILLED) {
8+
return state.value;
9+
} else if (state.status === PromiseStatus.REJECTED) {
10+
throw state.reason;
11+
}
12+
13+
throw syncInspectable; // Trigger suspense by throwing the promise.
14+
}
Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,37 @@
1-
import React from "react";
21
import type { LazyActivityComponentType } from "../__internal__/LazyActivityComponentType";
32
import type { StaticActivityComponentType } from "../__internal__/StaticActivityComponentType";
3+
import { preloadableLazyComponent } from "../__internal__/utils/PreloadableLazyComponent";
4+
import {
5+
inspect,
6+
PromiseStatus,
7+
reject,
8+
resolve,
9+
} from "../__internal__/utils/SyncInspectablePromise";
410

511
export function lazy<T extends { [K in keyof T]: any } = {}>(
612
load: () => Promise<{ default: StaticActivityComponentType<T> }>,
713
): LazyActivityComponentType<T> {
8-
let cachedValue: Promise<{ default: StaticActivityComponentType<T> }> | null =
9-
null;
14+
const { Component, preload } = preloadableLazyComponent(() =>
15+
resolve(load()),
16+
);
1017

11-
const cachedLoad = () => {
12-
if (!cachedValue) {
13-
cachedValue = load();
14-
cachedValue.catch((error) => {
15-
cachedValue = null;
18+
const LazyActivityComponent: LazyActivityComponentType<T> = Object.assign(
19+
Component,
20+
{
21+
_load: () => {
22+
const preloadTask = resolve(preload());
23+
const preloadTaskState = inspect(preloadTask);
1624

17-
throw error;
18-
});
19-
}
20-
return cachedValue;
21-
};
25+
if (preloadTaskState.status === PromiseStatus.FULFILLED) {
26+
return resolve({ default: Component });
27+
} else if (preloadTaskState.status === PromiseStatus.REJECTED) {
28+
return reject(preloadTaskState.reason);
29+
}
2230

23-
const LazyActivityComponent: LazyActivityComponentType<T> =
24-
React.lazy(cachedLoad);
25-
LazyActivityComponent._load = cachedLoad;
31+
return resolve(preloadTask.then(() => ({ default: Component })));
32+
},
33+
},
34+
);
2635

2736
return LazyActivityComponent;
2837
}

integrations/react/src/future/loader/loaderPlugin.tsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import type { ActivityComponentType } from "../../__internal__/ActivityComponent
66
import type { StackflowReactPlugin } from "../../__internal__/StackflowReactPlugin";
77
import { isStructuredActivityComponent } from "../../__internal__/StructuredActivityComponentType";
88
import { isPromiseLike } from "../../__internal__/utils/isPromiseLike";
9+
import {
10+
inspect,
11+
PromiseStatus,
12+
resolve,
13+
} from "../../__internal__/utils/SyncInspectablePromise";
914
import type { StackflowInput } from "../stackflow";
1015

1116
export function loaderPlugin<
@@ -35,7 +40,7 @@ export function loaderPlugin<
3540
...event,
3641
activityContext: {
3742
...event.activityContext,
38-
loaderData: initialContext.initialLoaderData,
43+
loaderData: resolve(initialContext.initialLoaderData),
3944
},
4045
};
4146
}
@@ -109,29 +114,28 @@ function createBeforeRouteHandler<
109114
}
110115

111116
const loaderData =
112-
matchActivity.loader && loadData(activityName, activityParams);
113-
114-
const loaderDataPromise = isPromiseLike(loaderData)
115-
? loaderData
116-
: undefined;
117-
const lazyComponentPromise =
117+
matchActivity.loader && resolve(loadData(activityName, activityParams));
118+
const lazyComponentPromise = resolve(
118119
isStructuredActivityComponent(matchActivityComponent) &&
119-
typeof matchActivityComponent.content === "function"
120+
typeof matchActivityComponent.content === "function"
120121
? matchActivityComponent.content()
121-
: "_load" in matchActivityComponent
122-
? matchActivityComponent._load?.()
123-
: undefined;
122+
: "_load" in matchActivityComponent &&
123+
typeof matchActivityComponent._load === "function"
124+
? matchActivityComponent._load()
125+
: undefined,
126+
);
124127
const shouldRenderImmediately = (activityContext as any)
125128
?.lazyActivityComponentRenderContext?.shouldRenderImmediately;
126129

127130
if (
128-
(loaderDataPromise || lazyComponentPromise) &&
131+
((loaderData && inspect(loaderData).status === PromiseStatus.PENDING) ||
132+
inspect(lazyComponentPromise).status === PromiseStatus.PENDING) &&
129133
(shouldRenderImmediately !== true ||
130134
"loading" in matchActivityComponent === false)
131135
) {
132136
pause();
133137

134-
Promise.allSettled([loaderDataPromise, lazyComponentPromise])
138+
Promise.allSettled([loaderData, lazyComponentPromise])
135139
.then(([loaderDataPromiseResult, lazyComponentPromiseResult]) => {
136140
printLoaderDataPromiseError({
137141
promiseResult: loaderDataPromiseResult,

integrations/react/src/future/loader/use.ts

Lines changed: 0 additions & 31 deletions
This file was deleted.

0 commit comments

Comments
 (0)