Skip to content
Open
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/shiny-ears-draw.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stackflow/plugin-history-sync": minor
---

Add an option to skip default history setup transition
249 changes: 157 additions & 92 deletions extensions/plugin-history-sync/src/historySyncPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import type { NavigationProcess } from "./NavigationProcess/NavigationProcess";
import { SerialNavigationProcess } from "./NavigationProcess/SerialNavigationProcess";
import { normalizeActivityRouteMap } from "./normalizeActivityRouteMap";
import { Publisher } from "./Publisher";
import type { RouteLike } from "./RouteLike";
import type { HistoryEntry, RouteLike } from "./RouteLike";
import { RoutesProvider } from "./RoutesContext";
import { sortActivityRoutes } from "./sortActivityRoutes";

Expand Down Expand Up @@ -64,6 +64,7 @@ type HistorySyncPluginOptions<T, K extends Extract<keyof T, string>> = (
useHash?: boolean;
history?: History;
urlPatternOptions?: UrlPatternOptions;
skipDefaultHistorySetupTransition?: boolean;
};

export function historySyncPlugin<
Expand Down Expand Up @@ -257,110 +258,174 @@ export function historySyncPlugin<
};
const defaultHistory =
targetActivityRoute.defaultHistory?.(params) ?? [];

initialSetupProcess = new SerialNavigationProcess([
...defaultHistory.map(
({ activityName, activityParams, additionalSteps = [] }) =>
() => {
const events: (
| Omit<PushedEvent, "eventDate">
| Omit<StepPushedEvent, "eventDate">
)[] = [
{
name: "Pushed",
id: id(),
activityId: id(),
activityName,
activityParams: {
...activityParams,
},
activityContext: {
path: currentPath,
lazyActivityComponentRenderContext: {
shouldRenderImmediately: true,
},
},
},
...additionalSteps.map(
({
stepParams,
hasZIndex,
}): Omit<StepPushedEvent, "eventDate"> => ({
name: "StepPushed",
id: id(),
stepId: id(),
stepParams,
hasZIndex,
}),
),
];

for (const event of events) {
if (event.name === "Pushed") {
activityActivationMonitors.push(
new DefaultHistoryActivityActivationMonitor(
event.activityId,
initialSetupProcess!,
),
);
}
}

return events;
const historyEntryToEvents = ({
activityName,
activityParams,
additionalSteps = [],
}: HistoryEntry): (
| Omit<PushedEvent, "eventDate">
| Omit<StepPushedEvent, "eventDate">
)[] => [
{
name: "Pushed",
id: id(),
activityId: id(),
activityName,
activityParams: {
...activityParams,
},
activityContext: {
path: currentPath,
lazyActivityComponentRenderContext: {
shouldRenderImmediately: true,
},
),
() => [
{
name: "Pushed",
},
},
...additionalSteps.map(
({
stepParams,
hasZIndex,
}): Omit<StepPushedEvent, "eventDate"> => ({
name: "StepPushed",
id: id(),
activityId: id(),
activityName: targetActivityRoute.activityName,
activityParams:
makeTemplate(
targetActivityRoute,
options.urlPatternOptions,
).parse(currentPath) ??
urlSearchParamsToMap(pathToUrl(currentPath).searchParams),
activityContext: {
path: currentPath,
lazyActivityComponentRenderContext: {
shouldRenderImmediately: true,
},
},
stepId: id(),
stepParams,
hasZIndex,
}),
),
];
const createTargetActivityPushEvent = (): Omit<
PushedEvent,
"eventDate"
> => ({
name: "Pushed",
id: id(),
activityId: id(),
activityName: targetActivityRoute.activityName,
activityParams:
makeTemplate(targetActivityRoute, options.urlPatternOptions).parse(
currentPath,
) ?? urlSearchParamsToMap(pathToUrl(currentPath).searchParams),
activityContext: {
path: currentPath,
lazyActivityComponentRenderContext: {
shouldRenderImmediately: true,
},
],
]);
},
});

if (options.skipDefaultHistorySetupTransition) {
initialSetupProcess = new SerialNavigationProcess([
() => [
...defaultHistory.flatMap((historyEntry) =>
historyEntryToEvents(historyEntry).map((event) => {
if (event.name !== "Pushed") return event;

activityActivationMonitors.push(
new DefaultHistoryActivityActivationMonitor(
event.activityId,
initialSetupProcess!,
),
);

return {
...event,
skipEnterActiveState: true,
};
}),
),
{
...createTargetActivityPushEvent(),
skipEnterActiveState: true,
},
],
]);
} else {
initialSetupProcess = new SerialNavigationProcess([
...defaultHistory.map((historyEntry, index) => () => {
let isFirstPush = true;

return historyEntryToEvents(historyEntry).map((event) => {
if (event.name !== "Pushed") return event;
const skipEnterActiveState = index === 0 && isFirstPush;

isFirstPush = false;
activityActivationMonitors.push(
new DefaultHistoryActivityActivationMonitor(
event.activityId,
initialSetupProcess!,
),
);

return {
...event,
skipEnterActiveState,
};
});
}),
() => [createTargetActivityPushEvent()],
]);
}

const now = Date.now();

return initialSetupProcess
.captureNavigationOpportunity(null)
.map((event) => ({
.map((event, index, array) => ({
...event,
eventDate: Date.now() - MINUTE,
eventDate: now - (array.length - index),
}));
},
onInit({ actions: { getStack, dispatchEvent, push, stepPush } }) {
const stack = getStack();
const rootActivity = stack.activities[0];

const match = activityRoutes.find(
(r) => r.activityName === rootActivity.name,
)!;
const template = makeTemplate(match, options.urlPatternOptions);

const lastStep = last(rootActivity.steps);
if (parseState(history.location.state) === null) {
for (const activity of stack.activities) {
if (
activity.transitionState === "enter-active" ||
activity.transitionState === "enter-done"
) {
const match = activityRoutes.find(
(r) => r.activityName === activity.name,
)!;
Comment on lines +388 to +390
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add defensive handling for missing route match.

The non-null assertion on activityRoutes.find() could throw if an activity's name doesn't match any configured route. While this is unlikely in normal operation, it could cause initialization failures for edge cases or misconfigured activities.

🔎 Proposed defensive handling
-              const match = activityRoutes.find(
-                (r) => r.activityName === activity.name,
-              )!;
+              const match = activityRoutes.find(
+                (r) => r.activityName === activity.name,
+              );
+              if (!match) continue;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const match = activityRoutes.find(
(r) => r.activityName === activity.name,
)!;
const match = activityRoutes.find(
(r) => r.activityName === activity.name,
);
if (!match) continue;
🤖 Prompt for AI Agents
In extensions/plugin-history-sync/src/historySyncPlugin.tsx around lines 388 to
390, the code uses a non-null assertion on activityRoutes.find(...) which will
throw if no route matches an activity name; replace the assertion with a safe
lookup: check the result of find, if it's undefined log a warning (including
activity.name for debugging) and skip or provide a sensible fallback handler so
initialization doesn't crash; ensure subsequent code branches handle the absent
route case gracefully.

const template = makeTemplate(match, options.urlPatternOptions);

if (activity.isRoot) {
replaceState({
history,
pathname: template.fill(activity.params),
state: {
activity: activity,
},
useHash: options.useHash,
});
} else {
pushState({
history,
pathname: template.fill(activity.params),
state: {
activity: activity,
},
useHash: options.useHash,
});
}

requestHistoryTick(() => {
silentFlag = true;
replaceState({
history,
pathname: template.fill(rootActivity.params),
state: {
activity: rootActivity,
step: lastStep,
},
useHash: options.useHash,
});
});
for (const step of activity.steps) {
if (!step.exitedBy && step.enteredBy.name !== "Pushed") {
pushState({
history,
pathname: template.fill(step.params),
state: {
activity: activity,
step: step,
},
useHash: options.useHash,
});
}
}
}
}
}

const onPopState: Listener = (e) => {
if (silentFlag) {
Expand Down
Loading