Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support <Link /> in Future API #509

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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
9 changes: 9 additions & 0 deletions .changeset/heavy-pumpkins-obey.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@stackflow/plugin-history-sync": minor
"@stackflow/react": minor
"@stackflow/link": minor
"@stackflow/config": minor
"@stackflow/demo": minor
---

feat: Support <Link /> in Future API
1,066 changes: 206 additions & 860 deletions .pnp.cjs

Large diffs are not rendered by default.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file removed .yarn/cache/fsevents-patch-19706e7e35-10.zip
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
12 changes: 7 additions & 5 deletions config/src/Config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { ActivityDefinition } from "./ActivityDefinition";
import type { ConfigDefinition } from "./ConfigDefinition";

export type Config<T extends ActivityDefinition<string>> = {
activities: T[];
transitionDuration: number;
initialActivity?: () => T["name"];
};
export interface Config<T extends ActivityDefinition<string>>
extends ConfigDefinition<T> {
decorate<
K extends Exclude<keyof Config<T>, keyof ConfigDefinition<T> | "decorate">,
>(key: K, value: Config<T>[K]): void;
}
7 changes: 7 additions & 0 deletions config/src/ConfigDefinition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { ActivityDefinition } from "./ActivityDefinition";

export interface ConfigDefinition<T extends ActivityDefinition<string>> {
activities: T[];
transitionDuration: number;
initialActivity?: () => T["name"];
}
10 changes: 9 additions & 1 deletion config/src/defineConfig.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import type { ActivityDefinition } from "./ActivityDefinition";
import type { Config } from "./Config";
import type { ConfigDefinition } from "./ConfigDefinition";

export function defineConfig<
ActivityName extends string,
Activity extends ActivityDefinition<ActivityName>,
>(config: Config<Activity>) {
>(configDefinition: ConfigDefinition<Activity>): Config<Activity> {
const config: Config<Activity> = {
...configDefinition,
decorate(key, value) {
config[key] = value;
},
};

return config;
}
2 changes: 1 addition & 1 deletion demo/src/components/ArticleCard.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { style } from "@vanilla-extract/css";

import { f } from "../styles";

export const container = style([f.resetButton, f.cursorPointer]);
export const container = style([f.resetAnchor, f.cursorPointer]);

export const thumbnail = style([
f.posRel,
Expand Down
19 changes: 7 additions & 12 deletions demo/src/components/ArticleCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useFlow } from "@stackflow/react/future";
import { Link } from "@stackflow/link/future";
import { LazyLoadImage } from "react-lazy-load-image-component";
import * as css from "./ArticleCard.css";

Expand All @@ -12,19 +12,14 @@ const ArticleCard: React.FC<ArticleCardProps> = ({
title,
price,
}) => {
const { push } = useFlow();

const imageUrl = `https://picsum.photos/800/800/?id=${articleId}`;

const onClick = () => {
push("Article", {
articleId: String(articleId),
title,
});
};

return (
<button type="button" className={css.container} onClick={onClick}>
<Link
activityName="Article"
activityParams={{ articleId: String(articleId), title }}
className={css.container}
>
<div className={css.thumbnail}>
<div className={css.innerImage}>
<LazyLoadImage
Expand All @@ -37,7 +32,7 @@ const ArticleCard: React.FC<ArticleCardProps> = ({
</div>
<div className={css.title}>{title}</div>
<div className={css.price}>£{price}.00</div>
</button>
</Link>
);
};

Expand Down
5 changes: 5 additions & 0 deletions demo/src/styles/f.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ export const resetButton = style({
textAlign: "left",
});

export const resetAnchor = style({
color: vars.$scale.color.gray900,
textDecoration: "none",
});

export const cursorPointer = style({
cursor: "pointer",
WebkitTapHighlightColor: "transparent",
Expand Down
25 changes: 21 additions & 4 deletions extensions/link/esbuild.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
const { context } = require("esbuild");
const config = require("@stackflow/esbuild-config");
const {
esbuildPluginFilePathExtensions,
} = require("esbuild-plugin-file-path-extensions");
const pkg = require("./package.json");

const watch = process.argv.includes("--watch");
Expand All @@ -10,19 +13,33 @@ const external = Object.keys({

Promise.all([
context({
...config({}),
...config({
entryPoints: ["./src/**/*"],
outdir: "dist",
}),
bundle: false,
sourcemap: false,
external: undefined,
format: "cjs",
external,
}).then((ctx) =>
watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()),
),
context({
...config({}),
...config({
entryPoints: ["./src/**/*"],
outdir: "dist",
}),
bundle: true,
sourcemap: false,
external,
format: "esm",
outExtension: {
".js": ".mjs",
},
external,
plugins: [esbuildPluginFilePathExtensions()],

// https://github.com/favware/esbuild-plugin-file-path-extensions/blob/b8efeff0489c1b02540109f6ea8c39fcd90f9dfc/src/index.ts#L202
platform: "node",
}).then((ctx) =>
watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()),
),
Expand Down
12 changes: 12 additions & 0 deletions extensions/link/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@
"types": "./dist/index.d.ts",
"require": "./dist/index.js",
"import": "./dist/index.mjs"
},
"./stable": {
"types": "./dist/stable/index.d.ts",
"require": "./dist/stable/index.js",
"import": "./dist/stable/index.mjs"
},
"./future": {
"types": "./dist/future/index.d.ts",
"require": "./dist/future/index.js",
"import": "./dist/future/index.mjs"
}
},
"main": "./dist/index.js",
Expand All @@ -31,13 +41,15 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@stackflow/config": "^1.1.0",
"@stackflow/core": "^1.1.0",
"@stackflow/esbuild-config": "^1.0.3",
"@stackflow/plugin-history-sync": "^1.6.4-canary.0",
"@stackflow/plugin-preload": "^1.4.3-canary.0",
"@stackflow/react": "^1.3.2",
"@types/react": "^18.3.3",
"esbuild": "^0.23.0",
"esbuild-plugin-file-path-extensions": "^2.1.3",
"react": "^18.3.1",
"rimraf": "^3.0.2",
"typescript": "^5.5.3"
Expand Down
112 changes: 112 additions & 0 deletions extensions/link/src/future/Link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/// <reference types="@stackflow/plugin-history-sync" />

import type {
InferActivityParams,
RegisteredActivityParamTypes,
} from "@stackflow/config";
import { useConfig, useFlow } from "@stackflow/react/future";
import { useMemo } from "react";
import { omit } from "./omit";

type AnchorProps = Omit<
React.DetailedHTMLProps<
React.AnchorHTMLAttributes<HTMLAnchorElement>,
HTMLAnchorElement
>,
"ref" | "href"
>;

export interface LinkProps<
K extends Extract<keyof RegisteredActivityParamTypes, string>,
> extends AnchorProps {
ref?: React.RefObject<HTMLAnchorElement>;
activityName: K;
activityParams: InferActivityParams<K>;
animate?: boolean;
replace?: boolean;
}

export function Link<
K extends Extract<keyof RegisteredActivityParamTypes, string>,
>(props: LinkProps<K>) {
const config = useConfig();
const { push, replace } = useFlow();

const href = useMemo(() => {
const match = config.activities.find((r) => r.name === props.activityName);

if (
!match ||
!match.path ||
typeof match.path !== "string" ||
!config.historySync
) {
return undefined;
}

const { path } = match;
const { makeTemplate, urlPatternOptions } = config.historySync;

const template = makeTemplate({ path }, urlPatternOptions);

return template.fill(props.activityParams);
}, [config, props.activityName, props.activityParams]);

const anchorProps = omit(props, [
// Custom Props
"activityName",
"activityParams",
"animate",
"replace",

// Overriden Props
"onClick",
]);

const onClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (props.onClick) {
props.onClick(e);
}

/**
* https://github.com/gatsbyjs/gatsby/blob/33f18ba7a98780a887d33e72936da57a6c58932a/packages/gatsby-link/src/index.js#L182-L190
*/
if (
e.button === 0 && // ignore right clicks
!props.target && // let browser handle "target=_blank"
!e.defaultPrevented && // onClick prevented default
!e.metaKey && // ignore clicks with modifier keys...
!e.altKey &&
!e.ctrlKey &&
!e.shiftKey
) {
e.preventDefault();

if (props.replace) {
replace(
props.activityName,
props.activityParams,
typeof props.animate === "undefined" || props.animate === null
? {}
: { animate: props.animate },
);
} else {
push(
props.activityName,
props.activityParams,
typeof props.animate === "undefined" || props.animate === null
? {}
: { animate: props.animate },
);
}
}
};

return (
<a ref={props.ref} href={href} onClick={onClick} {...anchorProps}>
{props.children}
</a>
);
}

Link.displayName = "Link";
1 change: 1 addition & 0 deletions extensions/link/src/future/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./Link";
File renamed without changes.
3 changes: 1 addition & 2 deletions extensions/link/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export * from "./createLinkComponent";
export * from "./Link";
export * from "./stable";
File renamed without changes.
2 changes: 2 additions & 0 deletions extensions/link/src/stable/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./createLinkComponent";
export * from "./Link";
12 changes: 12 additions & 0 deletions extensions/link/src/stable/omit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export function omit<T extends {}, K extends keyof T>(
obj: T,
fieldNames: K[],
): Omit<T, K> {
const output = { ...obj };

fieldNames.forEach((fieldName) => {
delete output[fieldName];
});

return output;
}
19 changes: 17 additions & 2 deletions extensions/plugin-history-sync/src/historySyncPlugin.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { ActivityDefinition, Config } from "@stackflow/config";
import { id, makeEvent } from "@stackflow/core";
import type { StackflowReactPlugin } from "@stackflow/react";
import type { History, Listener } from "history";
import { createBrowserHistory, createMemoryHistory } from "history";

import type { ActivityDefinition, Config } from "@stackflow/config";
import { HistoryQueueProvider } from "./HistoryQueueContext";
import type { RouteLike } from "./RouteLike";
import { RoutesProvider } from "./RoutesContext";
Expand All @@ -18,10 +17,19 @@ import { sortActivityRoutes } from "./sortActivityRoutes";
const SECOND = 1000;
const MINUTE = 60 * SECOND;

type ConfigHistorySync = {
makeTemplate: typeof makeTemplate;
urlPatternOptions?: UrlPatternOptions;
};

declare module "@stackflow/config" {
interface ActivityDefinition<ActivityName extends string> {
path: string;
}

interface Config<T extends ActivityDefinition<string>> {
historySync?: ConfigHistorySync;
}
}

type HistorySyncPluginOptions<T, K extends Extract<keyof T, string>> = (
Expand All @@ -43,6 +51,13 @@ export function historySyncPlugin<
T extends { [activityName: string]: unknown },
K extends Extract<keyof T, string>,
>(options: HistorySyncPluginOptions<T, K>): StackflowReactPlugin<T> {
if ("config" in options) {
options.config.decorate("historySync", {
makeTemplate,
urlPatternOptions: options.urlPatternOptions,
});
}

const history =
options.history ??
(typeof window === "undefined"
Expand Down
18 changes: 18 additions & 0 deletions integrations/react/src/future/ConfigProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { ActivityDefinition, Config } from "@stackflow/config";
import type React from "react";
import { createContext } from "react";

export const ConfigContext = createContext<Config<ActivityDefinition<string>>>(
null as any,
);

interface ConfigProviderProps {
children: React.ReactNode;
value: Config<ActivityDefinition<string>>;
}
export const ConfigProvider: React.FC<ConfigProviderProps> = ({
children,
value,
}) => <ConfigContext.Provider value={value}>{children}</ConfigContext.Provider>;

ConfigProvider.displayName = "ConfigProvider";
1 change: 1 addition & 0 deletions integrations/react/src/future/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ export * from "./useActivityParams";
export * from "./loader/useLoaderData";
export * from "./useFlow";
export * from "./useStepFlow";
export * from "./useConfig";
Loading
Loading