Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/happy-ghosts-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stackflow/react": minor
---

utilize synchronously inspectable promise to optimize suspense fallback rendering
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { StaticActivityComponentType } from "./StaticActivityComponentType";

export type LazyActivityComponentType<T extends {} = {}> =
React.LazyExoticComponent<StaticActivityComponentType<T>> & {
StaticActivityComponentType<T> & {
_load?: () => Promise<{ default: StaticActivityComponentType<T> }>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import type {
InferActivityParams,
RegisteredActivityName,
} from "@stackflow/config";
import { type ComponentType, lazy, type ReactNode } from "react";
import type { ComponentType, ReactNode } from "react";
import { preloadableLazyComponent } from "./utils/PreloadableLazyComponent";
import {
inspect,
PromiseStatus,
reject,
resolve,
type SyncInspectablePromise,
} from "./utils/SyncInspectablePromise";

export const STRUCTURED_ACTIVITY_COMPONENT_TYPE: unique symbol = Symbol(
"STRUCTURED_ACTIVITY_COMPONENT_TYPE",
Expand All @@ -26,8 +34,26 @@ export function structuredActivityComponent<
loading?: Loading<InferActivityParams<ActivityName>>;
errorHandler?: ErrorHandler<InferActivityParams<ActivityName>>;
}): StructuredActivityComponentType<InferActivityParams<ActivityName>> {
const content = options.content;
let cachedContent: SyncInspectablePromise<{
default: Content<InferActivityParams<ActivityName>>;
}> | null = null;

return {
...options,
content:
typeof content !== "function"
? content
: () => {
if (
!cachedContent ||
inspect(cachedContent).status === PromiseStatus.REJECTED
) {
cachedContent = resolve(content());
}

return cachedContent;
},
[STRUCTURED_ACTIVITY_COMPONENT_TYPE]: true,
};
}
Expand Down Expand Up @@ -64,16 +90,27 @@ export function getContentComponent(
return ContentComponentMap.get(structuredActivityComponent)!;
}

const content = structuredActivityComponent.content;
const ContentComponent =
"component" in content
? content.component
: lazy(async () => {
const {
default: { component: Component },
} = await content();
return { default: Component };
});
const { Component: ContentComponent } = preloadableLazyComponent(() => {
const content = structuredActivityComponent.content;
const contentPromise = resolve(
typeof content === "function" ? content() : { default: content },
);
const state = inspect(contentPromise);

if (state.status === PromiseStatus.FULFILLED) {
return resolve({
default: state.value.default.component,
});
} else if (state.status === PromiseStatus.REJECTED) {
return reject(state.reason);
}

return resolve(
contentPromise.then((value) => ({
default: value.default.component,
})),
);
});

ContentComponentMap.set(structuredActivityComponent, ContentComponent);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ComponentType } from "react";
import {
inspect,
PromiseStatus,
type SyncInspectablePromise,
} from "./SyncInspectablePromise";
import { useThenable } from "./useThenable";

export function preloadableLazyComponent<P extends {}>(
load: () => SyncInspectablePromise<{ default: ComponentType<P> }>,
): { Component: ComponentType<P>; preload: () => Promise<void> } {
let cachedLoadingPromise: SyncInspectablePromise<{
default: ComponentType<P>;
}> | null = null;
const cachedLoad = () => {
if (
!cachedLoadingPromise ||
inspect(cachedLoadingPromise).status === PromiseStatus.REJECTED
) {
cachedLoadingPromise = load();
}

return cachedLoadingPromise;
};
const Component = (props: P) => {
const { default: Component } = useThenable(cachedLoad());

return <Component {...props} />;
};

return {
Component,
preload: async () => void (await cachedLoad()),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { isPromiseLike } from "./isPromiseLike";

export interface SyncInspectablePromise<T> extends Promise<T> {
status: PromiseStatus;
value?: T;
reason?: unknown;
}

export const PromiseStatus = {
PENDING: "pending",
FULFILLED: "fulfilled",
REJECTED: "rejected",
} as const;
export type PromiseStatus = (typeof PromiseStatus)[keyof typeof PromiseStatus];

export type PromiseState<T> =
| {
status: typeof PromiseStatus.PENDING;
}
| {
status: typeof PromiseStatus.FULFILLED;
value: T;
}
| {
status: typeof PromiseStatus.REJECTED;
reason: unknown;
};

export function inspect<T>(
promise: SyncInspectablePromise<T>,
): PromiseState<T> {
if (promise.status === PromiseStatus.PENDING) {
return {
status: PromiseStatus.PENDING,
};
} else if (promise.status === PromiseStatus.FULFILLED && "value" in promise) {
return {
status: PromiseStatus.FULFILLED,
value: promise.value as T,
};
} else if (promise.status === PromiseStatus.REJECTED && "reason" in promise) {
return {
status: PromiseStatus.REJECTED,
reason: promise.reason,
};
} else {
throw new Error("Invalid promise state");
}
}

function makeSyncInspectable<T>(
thenable: PromiseLike<T>,
): SyncInspectablePromise<T> {
const syncInspectablePromise: SyncInspectablePromise<T> = Object.assign(
new Promise<T>((resolve) => resolve(thenable)),
{ status: PromiseStatus.PENDING },
);

syncInspectablePromise.then(
(value) => {
syncInspectablePromise.status = PromiseStatus.FULFILLED;
syncInspectablePromise.value = value;
},
(reason) => {
syncInspectablePromise.status = PromiseStatus.REJECTED;
syncInspectablePromise.reason = reason;
},
);

return syncInspectablePromise;
}

export function resolve<T>(value: T): SyncInspectablePromise<Awaited<T>> {
if (isPromiseLike(value)) {
if (
value instanceof Promise &&
"status" in value &&
Object.values(PromiseStatus).some((status) => status === value.status)
) {
return value as SyncInspectablePromise<Awaited<T>>;
}

return makeSyncInspectable(value) as SyncInspectablePromise<Awaited<T>>;
}

return Object.assign(Promise.resolve(value), {
status: PromiseStatus.FULFILLED,
value,
}) as SyncInspectablePromise<Awaited<T>>;
}

export function reject(error: unknown): SyncInspectablePromise<never> {
return Object.assign(Promise.reject(error), {
status: PromiseStatus.REJECTED,
reason: error,
}) as SyncInspectablePromise<never>;
}
14 changes: 14 additions & 0 deletions integrations/react/src/__internal__/utils/useThenable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { inspect, PromiseStatus, resolve } from "./SyncInspectablePromise";

export function useThenable<T>(thenable: PromiseLike<T>): Awaited<T> {
const syncInspectable = resolve(thenable);
const state = inspect(syncInspectable);

if (state.status === PromiseStatus.FULFILLED) {
return state.value;
} else if (state.status === PromiseStatus.REJECTED) {
throw state.reason;
}

throw syncInspectable; // Trigger suspense by throwing the promise.
}
41 changes: 25 additions & 16 deletions integrations/react/src/future/lazy.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
import React from "react";
import type { LazyActivityComponentType } from "../__internal__/LazyActivityComponentType";
import type { StaticActivityComponentType } from "../__internal__/StaticActivityComponentType";
import { preloadableLazyComponent } from "../__internal__/utils/PreloadableLazyComponent";
import {
inspect,
PromiseStatus,
reject,
resolve,
} from "../__internal__/utils/SyncInspectablePromise";

export function lazy<T extends { [K in keyof T]: any } = {}>(
load: () => Promise<{ default: StaticActivityComponentType<T> }>,
): LazyActivityComponentType<T> {
let cachedValue: Promise<{ default: StaticActivityComponentType<T> }> | null =
null;
const { Component, preload } = preloadableLazyComponent(() =>
resolve(load()),
);

const cachedLoad = () => {
if (!cachedValue) {
cachedValue = load();
cachedValue.catch((error) => {
cachedValue = null;
const LazyActivityComponent: LazyActivityComponentType<T> = Object.assign(
Component,
{
_load: () => {
const preloadTask = resolve(preload());
const preloadTaskState = inspect(preloadTask);

throw error;
});
}
return cachedValue;
};
if (preloadTaskState.status === PromiseStatus.FULFILLED) {
return resolve({ default: Component });
} else if (preloadTaskState.status === PromiseStatus.REJECTED) {
return reject(preloadTaskState.reason);
}

const LazyActivityComponent: LazyActivityComponentType<T> =
React.lazy(cachedLoad);
LazyActivityComponent._load = cachedLoad;
return resolve(preloadTask.then(() => ({ default: Component })));
},
},
);

return LazyActivityComponent;
}
30 changes: 17 additions & 13 deletions integrations/react/src/future/loader/loaderPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import type { ActivityComponentType } from "../../__internal__/ActivityComponent
import type { StackflowReactPlugin } from "../../__internal__/StackflowReactPlugin";
import { isStructuredActivityComponent } from "../../__internal__/StructuredActivityComponentType";
import { isPromiseLike } from "../../__internal__/utils/isPromiseLike";
import {
inspect,
PromiseStatus,
resolve,
} from "../../__internal__/utils/SyncInspectablePromise";
import type { StackflowInput } from "../stackflow";

export function loaderPlugin<
Expand Down Expand Up @@ -35,7 +40,7 @@ export function loaderPlugin<
...event,
activityContext: {
...event.activityContext,
loaderData: initialContext.initialLoaderData,
loaderData: resolve(initialContext.initialLoaderData),
},
};
}
Expand Down Expand Up @@ -109,29 +114,28 @@ function createBeforeRouteHandler<
}

const loaderData =
matchActivity.loader && loadData(activityName, activityParams);

const loaderDataPromise = isPromiseLike(loaderData)
? loaderData
: undefined;
const lazyComponentPromise =
matchActivity.loader && resolve(loadData(activityName, activityParams));
const lazyComponentPromise = resolve(
isStructuredActivityComponent(matchActivityComponent) &&
typeof matchActivityComponent.content === "function"
typeof matchActivityComponent.content === "function"
? matchActivityComponent.content()
: "_load" in matchActivityComponent
? matchActivityComponent._load?.()
: undefined;
: "_load" in matchActivityComponent &&
typeof matchActivityComponent._load === "function"
? matchActivityComponent._load()
: undefined,
);
const shouldRenderImmediately = (activityContext as any)
?.lazyActivityComponentRenderContext?.shouldRenderImmediately;

if (
(loaderDataPromise || lazyComponentPromise) &&
((loaderData && inspect(loaderData).status === PromiseStatus.PENDING) ||
inspect(lazyComponentPromise).status === PromiseStatus.PENDING) &&
(shouldRenderImmediately !== true ||
"loading" in matchActivityComponent === false)
) {
pause();

Promise.allSettled([loaderDataPromise, lazyComponentPromise])
Promise.allSettled([loaderData, lazyComponentPromise])
.then(([loaderDataPromiseResult, lazyComponentPromiseResult]) => {
printLoaderDataPromiseError({
promiseResult: loaderDataPromiseResult,
Expand Down
31 changes: 0 additions & 31 deletions integrations/react/src/future/loader/use.ts

This file was deleted.

Loading
Loading