From 7545c6780edc5dfa87cb3bf5fc1c9bdf8ef4508d Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Fri, 6 Feb 2026 18:44:22 +0900 Subject: [PATCH 1/5] docs: add loader data invalidation design and implementation plan Co-Authored-By: Claude Opus 4.5 --- .../2026-02-04-loader-invalidation-design.md | 305 +++++++ .../2026-02-06-loader-invalidation-impl.md | 799 ++++++++++++++++++ 2 files changed, 1104 insertions(+) create mode 100644 docs/plans/2026-02-04-loader-invalidation-design.md create mode 100644 docs/plans/2026-02-06-loader-invalidation-impl.md diff --git a/docs/plans/2026-02-04-loader-invalidation-design.md b/docs/plans/2026-02-04-loader-invalidation-design.md new file mode 100644 index 000000000..eeca4c8c8 --- /dev/null +++ b/docs/plans/2026-02-04-loader-invalidation-design.md @@ -0,0 +1,305 @@ +# Loader Data Invalidation Design + +## Overview + +Stackflow의 Loader 시스템에 invalidation 기능을 추가한다. 캐시 레이어는 외부(TanStack Query 등)에 위임하고, Stackflow는 "언제 loader를 다시 호출할지"만 결정하는 unopinionated한 인터페이스를 제공한다. + +## Goals + +1. **명시적 invalidation**: 개발자가 `invalidate()` 함수를 직접 호출하여 loader 재실행 +2. **Activity 상태 기반 invalidation**: `shouldInvalidate` 콜백으로 activity 상태 변화 시 자동 재실행 제어 +3. **하위 호환성**: 기존 `useLoaderData()` API 유지 +4. **Unopinionated**: loader data를 Promise 그대로 반환하여 사용자가 Suspense 사용 여부 결정 + +## Non-Goals + +- 내장 캐시 레이어 제공 (외부 쿼리 클라이언트에 위임) +- 다른 activity의 loader invalidation (현재 activity만 대상) + +## Architecture + +### 핵심 문제 + +Core의 `activity.context`는 immutable하다. Event sourcing 패턴으로 인해 한번 저장된 `activityContext.loaderData`는 수정할 수 없다. + +### 해결 방안 + +`loaderPlugin`의 `wrapActivity` hook을 사용하여 각 Activity마다 독립적인 React Context를 제공한다. loaderData를 React state로 관리하여 invalidation 시 re-render를 트리거한다. + +``` +┌─────────────────────────────────────────────────────┐ +│ loaderPlugin.wrapActivity │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ ActivityLoaderProvider │ │ +│ │ - loaderData: state (초기값: context에서) │ │ +│ │ - invalidate: setState(loadData(...)) │ │ +│ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │ Activity Component │ │ │ +│ │ │ useLoader() → { data, invalidate } │ │ │ +│ │ └─────────────────────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ +``` + +## API Design + +### 1. Loader 설정 타입 확장 + +```typescript +// 기존: 함수만 전달 +type ActivityLoader = (args: ActivityLoaderArgs) => any; + +// 신규: 함수 또는 옵션 객체 +type ActivityLoaderConfig = + | ActivityLoader + | { + fn: ActivityLoader; + shouldInvalidate?: (args: { + prevActivity: Activity; + currentActivity: Activity; + }) => boolean; + }; +``` + +### 2. Config 사용 예시 + +```typescript +// stackflow.config.ts +import { defineConfig } from "@stackflow/config"; +import { articleLoader } from "./Article.loader"; + +export const config = defineConfig({ + activities: [ + // 단순 케이스 - 함수만 + { + name: "Home", + route: { path: "/" }, + loader: homeLoader, + }, + + // 고급 케이스 - shouldInvalidate 포함 + { + name: "Article", + route: { path: "/articles/:articleId" }, + loader: { + fn: articleLoader, + shouldInvalidate: ({ prevActivity, currentActivity }) => { + // activity가 다시 active 될 때 (back navigation) + return !prevActivity.isActive && currentActivity.isActive; + }, + }, + }, + ], +}); +``` + +### 3. `useLoader()` Hook + +```typescript +// @stackflow/react/future +export function useLoader any>(options: { + loaderFn: T; // 실제 loader 함수를 객체 프로퍼티로 전달 +}): { + data: ReturnType; // Promise | X - loader 반환 타입 그대로 + invalidate: () => void; +}; +``` + +**내부 동작:** +- `loaderFn` 프로퍼티를 통해 타입 자동 추론 +- 런타임에 config에 등록된 loader와 동일한지 검증 (불일치 시 에러) + +```typescript +function useLoader any>(options: { loaderFn: T }) { + const activity = useActivity(); + const configLoader = getLoaderFromConfig(activity.name); + + if (options.loaderFn !== configLoader) { + throw new Error( + `Loader mismatch: expected loader for "${activity.name}" activity` + ); + } + + // ... +} +``` + +**사용 예시:** + +```tsx +// Article.tsx +import { use } from "react"; +import { useLoader } from "@stackflow/react/future"; +import { articleLoader } from "./Article.loader"; + +const Article: ActivityComponentType = () => { + const { data, invalidate } = useLoader({ loaderFn: articleLoader }); + + // 사용자가 Suspense 사용 여부 결정 + const resolved = use(data); // Suspense 트리거 + + // 또는 TanStack Query와 조합 + // const query = useSuspenseQuery({ + // queryKey: ['article'], + // queryFn: () => data, + // }); + + return ( +
+

{resolved.title}

+ {resolved.title} + +
+ ); +}; +``` + +### 4. `useLoaderData()` 유지 (하위 호환) + +`useLoaderData()`는 기존 동작을 완전히 유지합니다. + +```typescript +// 기존 API 완전 유지 - 제네릭 제약, Suspense 메커니즘 모두 동일 +export function useLoaderData< + T extends (args: ActivityLoaderArgs) => any, // 기존 제네릭 제약 유지 +>(): Awaited> { + // 내부적으로 ActivityLoaderContext에서 데이터 가져옴 + // useLoader()를 사용하지 않음 (loader 파라미터 불필요) + const { loaderData } = useContext(ActivityLoaderContext); + return useThenable(resolve(loaderData)); // 기존 useThenable + resolve 유지 +} +``` + +**변경 없는 부분:** +- 제네릭 제약: `(args: ActivityLoaderArgs) => any` +- 반환 타입: `Awaited>` +- Suspense 메커니즘: `useThenable()` + `resolve()` (React 18 호환) +- 호출 방식: `useLoaderData()` + +**변경되는 부분:** +- 데이터 소스만 `activity.context` → `ActivityLoaderContext` + +## Behavior + +### `invalidate()` 호출 시 + +1. 현재 activity의 loader 함수를 다시 호출 +2. React state 업데이트 → re-render +3. 사용자가 `use(data)` 호출 시 Suspense fallback 표시 +4. Promise resolve 후 새 데이터로 렌더링 + +### `shouldInvalidate` 콜백 + +- Activity 상태가 변경될 때마다 호출됨 +- `prevActivity`: 이전 상태의 Activity 객체 전체 +- `currentActivity`: 현재 상태의 Activity 객체 전체 +- `true` 반환 시 loader 자동 재호출 + +**구현 방식:** + +`ActivityLoaderProvider` 내부에서 Core store를 구독하여 activity 상태 변화를 감지합니다. +useEffect가 아닌 event-based subscription 방식으로, React의 권장 패턴을 따릅니다. + +```typescript +// ActivityLoaderProvider 내부 +const actions = useCoreActions(); +const prevActivityRef = useRef(activity); + +useEffect(() => { + return actions.subscribe(() => { + const stack = actions.getStack(); + const currentActivity = stack.activities.find(a => a.id === activity.id); + const prevActivity = prevActivityRef.current; + + if (shouldInvalidate?.({ prevActivity, currentActivity })) { + setLoaderData(loadData(currentActivity.name, currentActivity.params)); + } + + prevActivityRef.current = currentActivity; + }); +}, []); +``` + +**주요 사용 시나리오:** + +```typescript +// Back navigation으로 돌아왔을 때 +shouldInvalidate: ({ prevActivity, currentActivity }) => { + return !prevActivity.isActive && currentActivity.isActive; +} + +// 특정 transition state 도달 시 +shouldInvalidate: ({ prevActivity, currentActivity }) => { + return currentActivity.transitionState === "enter-done" + && prevActivity.transitionState !== "enter-done"; +} +``` + +## Implementation Plan + +### Phase 1: Type 정의 수정 + +1. `@stackflow/config`의 `ActivityLoader` 타입을 `ActivityLoaderConfig`로 확장 +2. `ActivityDefinition`에서 새 타입 사용 +3. Loader 함수 추출 유틸리티 함수 추가 (`getLoaderFn`, `getShouldInvalidate`) + +### Phase 2: ActivityLoaderContext 구현 + +1. `ActivityLoaderContext` 생성 (`integrations/react/src/future/loader/`) +2. `ActivityLoaderProvider` 컴포넌트 구현 + - `loaderData`를 React state로 관리 + - `invalidate` 함수 제공 +3. `loaderPlugin`의 `wrapActivity` hook에서 Provider 적용 + +### Phase 3: `useLoader()` Hook 구현 + +1. `useLoader()` hook 생성 + - Context에서 `{ data, invalidate }` 반환 + - data는 loader 반환 타입 그대로 (Promise일 수도, 아닐 수도) +2. `useLoaderData()` 내부 구현을 `useLoader()` + `useThenable()` 기반으로 변경 + - 기존 제네릭 제약 유지: `(args: ActivityLoaderArgs) => any` + - 기존 Suspense 메커니즘 유지: `useThenable(resolve(data))` + +### Phase 4: `shouldInvalidate` 콜백 지원 + +1. `loaderPlugin`에서 activity 상태 변화 감지 +2. `shouldInvalidate` 콜백 호출 로직 추가 +3. `true` 반환 시 자동 invalidation 트리거 + +### Phase 5: 테스트 및 문서화 + +1. Unit tests for `useLoader()` hook +2. Integration tests for `shouldInvalidate` scenarios +3. Demo app에 예제 추가 +4. API documentation 업데이트 + +## Migration Guide + +기존 코드는 변경 없이 동작합니다: + +```typescript +// Before & After - 둘 다 동작 (Suspense 자동 트리거) +const data = useLoaderData(); + +// 새 기능: invalidation + Suspense 제어 +import { articleLoader } from "./Article.loader"; + +const { data, invalidate } = useLoader({ loaderFn: articleLoader }); +const resolved = use(data); // 직접 Suspense 제어 +``` + +## Resolved Questions + +1. ~~`invalidate()` 호출 시 Suspense vs stale-while-revalidate~~ → **사용자가 `use()` 호출 여부로 결정** +2. ~~다른 activity의 loader도 invalidate 가능해야 하나?~~ → **현재 activity만 지원** +3. ~~loader data 저장 방식~~ → **React Context + state로 관리 (Core immutable 유지)** +4. ~~`useLoader()` 타입 안정성~~ → **`{ loaderFn }` 객체 파라미터로 전달 + 런타임 검증** +5. ~~`shouldInvalidate` 트리거 방식~~ → **Core store 구독 (event-based, useEffect 아님)** + +## References + +- [TanStack Router - Data Loading](https://tanstack.com/router/v1/docs/framework/react/guide/data-loading) +- [React Router - useRevalidator](https://reactrouter.com/en/main/hooks/use-revalidator) +- [React Router - shouldRevalidate](https://reactrouter.com/en/main/route/should-revalidate) diff --git a/docs/plans/2026-02-06-loader-invalidation-impl.md b/docs/plans/2026-02-06-loader-invalidation-impl.md new file mode 100644 index 000000000..69c943df3 --- /dev/null +++ b/docs/plans/2026-02-06-loader-invalidation-impl.md @@ -0,0 +1,799 @@ +# Loader Data Invalidation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Loader Data Invalidation 기능 구현 - `useLoader()` hook과 `shouldInvalidate` 콜백을 통해 loader data를 재호출할 수 있는 메커니즘 제공 + +**Architecture:** loaderPlugin의 `wrapActivity` hook을 사용하여 Activity마다 독립적인 React Context를 제공한다. loaderData를 React state로 관리하고, Core store 구독을 통해 activity 상태 변화 감지 시 `shouldInvalidate` 콜백을 호출한다. + +**Tech Stack:** TypeScript, React (useState, useContext, useEffect, useCallback, useRef) + +**Design Document:** `docs/plans/2026-02-04-loader-invalidation-design.md` 참조 + +--- + +## Task 1: ActivityLoaderConfig 타입 정의 + +**Files:** +- Modify: `config/src/ActivityLoader.ts` +- Modify: `config/src/ActivityDefinition.ts` +- Modify: `config/src/index.ts` + +**Step 1: ActivityLoaderConfig 타입 추가** + +`config/src/ActivityLoader.ts` 파일 끝에 추가: + +```typescript +import type { Activity } from "@stackflow/core"; + +export interface ActivityLoaderConfigObject< + ActivityName extends RegisteredActivityName, +> { + fn: ActivityLoader; + shouldInvalidate?: (args: { + prevActivity: Activity; + currentActivity: Activity; + }) => boolean; +} + +export type ActivityLoaderConfig = + | ActivityLoader + | ActivityLoaderConfigObject; +``` + +**Step 2: 유틸리티 함수 추가** + +`config/src/ActivityLoader.ts` 파일에 추가: + +```typescript +export function getLoaderFn( + loaderConfig: ActivityLoaderConfig | undefined, +): ActivityLoader | undefined { + if (!loaderConfig) { + return undefined; + } + if (typeof loaderConfig === "function") { + return loaderConfig; + } + return loaderConfig.fn; +} + +export function getShouldInvalidate( + loaderConfig: ActivityLoaderConfig | undefined, +): ActivityLoaderConfigObject["shouldInvalidate"] | undefined { + if (!loaderConfig || typeof loaderConfig === "function") { + return undefined; + } + return loaderConfig.shouldInvalidate; +} +``` + +**Step 3: ActivityDefinition 타입 수정** + +`config/src/ActivityDefinition.ts` 파일의 `loader` 프로퍼티 타입 변경: + +```typescript +import type { ActivityLoaderConfig } from "./ActivityLoader"; + +export interface ActivityDefinition< + ActivityName extends RegisteredActivityName, +> { + name: ActivityName; + loader?: ActivityLoaderConfig; // ActivityLoader에서 ActivityLoaderConfig로 변경 +} +``` + +**Step 4: index.ts에서 export 추가** + +`config/src/index.ts`에 추가: + +```typescript +export type { + ActivityLoaderConfig, + ActivityLoaderConfigObject, +} from "./ActivityLoader"; +export { getLoaderFn, getShouldInvalidate } from "./ActivityLoader"; +``` + +**Step 5: 빌드 확인** + +Run: `cd config && yarn build` +Expected: 빌드 성공 + +**Step 6: Commit** + +```bash +git add config/src/ActivityLoader.ts config/src/ActivityDefinition.ts config/src/index.ts +git commit -m "$(cat <<'EOF' +feat(config): add ActivityLoaderConfig type with shouldInvalidate support + +- Add ActivityLoaderConfig union type (function | object with fn + shouldInvalidate) +- Add getLoaderFn and getShouldInvalidate utility functions +- Update ActivityDefinition to use ActivityLoaderConfig + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 2: ActivityLoaderContext 생성 + +**Files:** +- Create: `integrations/react/src/future/loader/ActivityLoaderContext.tsx` + +**Step 1: Context 파일 생성** + +`integrations/react/src/future/loader/ActivityLoaderContext.tsx`: + +```typescript +import { createContext } from "react"; + +export interface ActivityLoaderContextValue { + loaderData: unknown; + invalidate: () => void; +} + +export const ActivityLoaderContext = + createContext(null); +``` + +**Step 2: 빌드 확인** + +Run: `cd integrations/react && yarn build` +Expected: 빌드 성공 + +**Step 3: Commit** + +```bash +git add integrations/react/src/future/loader/ActivityLoaderContext.tsx +git commit -m "$(cat <<'EOF' +feat(react): create ActivityLoaderContext for loader data management + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 3: ActivityLoaderProvider 컴포넌트 구현 + +**Files:** +- Create: `integrations/react/src/future/loader/ActivityLoaderProvider.tsx` + +**Step 1: Provider 컴포넌트 생성** + +`integrations/react/src/future/loader/ActivityLoaderProvider.tsx`: + +```typescript +import type { Activity } from "@stackflow/core"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useCoreActions } from "../../__internal__/core/useCoreActions"; +import { ActivityLoaderContext } from "./ActivityLoaderContext"; + +interface ActivityLoaderProviderProps { + activity: Activity; + initialLoaderData: unknown; + loadData: (activityName: string, activityParams: {}) => unknown; + shouldInvalidate?: (args: { + prevActivity: Activity; + currentActivity: Activity; + }) => boolean; + children: ReactNode; +} + +export function ActivityLoaderProvider({ + activity, + initialLoaderData, + loadData, + shouldInvalidate, + children, +}: ActivityLoaderProviderProps) { + const [loaderData, setLoaderData] = useState(initialLoaderData); + const actions = useCoreActions(); + const prevActivityRef = useRef(activity); + + const invalidate = useCallback(() => { + const newLoaderData = loadData(activity.name, activity.params); + setLoaderData(newLoaderData); + }, [activity.name, activity.params, loadData]); + + useEffect(() => { + if (!shouldInvalidate) { + return; + } + + const unsubscribe = actions.subscribe(() => { + const stack = actions.getStack(); + const currentActivity = stack.activities.find( + (a) => a.id === activity.id, + ); + + if (!currentActivity) { + return; + } + + const prevActivity = prevActivityRef.current; + + if (shouldInvalidate({ prevActivity, currentActivity })) { + const newLoaderData = loadData( + currentActivity.name, + currentActivity.params, + ); + setLoaderData(newLoaderData); + } + + prevActivityRef.current = currentActivity; + }); + + return unsubscribe; + }, [actions, activity.id, loadData, shouldInvalidate]); + + return ( + + {children} + + ); +} +``` + +**Step 2: 빌드 확인** + +Run: `cd integrations/react && yarn build` +Expected: 빌드 성공 + +**Step 3: Commit** + +```bash +git add integrations/react/src/future/loader/ActivityLoaderProvider.tsx +git commit -m "$(cat <<'EOF' +feat(react): implement ActivityLoaderProvider with invalidation support + +- Manage loaderData as React state +- Subscribe to Core store for activity state changes +- Call shouldInvalidate callback on state changes +- Provide invalidate function via context + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 4: useLoader hook 구현 + +**Files:** +- Create: `integrations/react/src/future/loader/useLoader.ts` + +**Step 1: useLoader hook 생성** + +`integrations/react/src/future/loader/useLoader.ts`: + +```typescript +import { useContext } from "react"; +import { useActivity } from "../../stable"; +import { ActivityLoaderContext } from "./ActivityLoaderContext"; +import { useConfig } from "../config/useConfig"; +import { getLoaderFn } from "@stackflow/config"; + +export function useLoader any>(options: { + loaderFn: T; +}): { + data: ReturnType; + invalidate: () => void; +} { + const activity = useActivity(); + const config = useConfig(); + const context = useContext(ActivityLoaderContext); + + if (!context) { + throw new Error( + "useLoader() must be used within an ActivityLoaderProvider. " + + "Make sure you are using the loaderPlugin.", + ); + } + + // Runtime validation: check if the provided loader matches the config + const activityConfig = config.activities.find( + (a) => a.name === activity.name, + ); + const configLoaderFn = getLoaderFn(activityConfig?.loader); + + if (options.loaderFn !== configLoaderFn) { + throw new Error( + `Loader mismatch: the provided loader does not match the loader ` + + `registered for "${activity.name}" activity in the config.`, + ); + } + + return { + data: context.loaderData as ReturnType, + invalidate: context.invalidate, + }; +} +``` + +**Step 2: 빌드 확인** + +Run: `cd integrations/react && yarn build` +Expected: 빌드 성공 + +**Step 3: Commit** + +```bash +git add integrations/react/src/future/loader/useLoader.ts +git commit -m "$(cat <<'EOF' +feat(react): implement useLoader hook with runtime validation + +- Accept loaderFn via object parameter for type inference +- Validate loader matches config at runtime +- Return data and invalidate function from context + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 5: loaderPlugin에 wrapActivity 추가 + +**Files:** +- Modify: `integrations/react/src/future/loader/loaderPlugin.tsx` + +**Step 1: import 추가** + +`loaderPlugin.tsx` 파일 상단에 import 추가: + +```typescript +import { getLoaderFn, getShouldInvalidate } from "@stackflow/config"; +import { ActivityLoaderProvider } from "./ActivityLoaderProvider"; +``` + +**Step 2: wrapActivity hook 추가** + +`loaderPlugin()` 함수 내부 return 객체에 `wrapActivity` 추가: + +```typescript +export function loaderPlugin< + T extends ActivityDefinition, + R extends { + [activityName in RegisteredActivityName]: ActivityComponentType; + }, +>( + input: StackflowInput, + loadData: (activityName: string, activityParams: {}) => unknown, +): StackflowReactPlugin { + return () => { + return { + key: "plugin-loader", + overrideInitialEvents({ initialEvents, initialContext }) { + // ... existing code ... + }, + onBeforePush: createBeforeRouteHandler(input, loadData), + onBeforeReplace: createBeforeRouteHandler(input, loadData), + wrapActivity({ activity, initialContext }) { + const matchActivity = input.config.activities.find( + (a) => a.name === activity.name, + ); + + if (!matchActivity?.loader) { + return <>{activity.render()}; + } + + const shouldInvalidate = getShouldInvalidate(matchActivity.loader); + const initialLoaderData = (activity.context as any)?.loaderData; + + return ( + + {activity.render()} + + ); + }, + }; + }; +} +``` + +**Step 3: getLoaderFn 사용하도록 기존 코드 수정** + +`overrideInitialEvents`와 `createBeforeRouteHandler`에서 `matchActivity.loader`를 직접 사용하던 부분을 `getLoaderFn(matchActivity.loader)`로 변경: + +```typescript +// overrideInitialEvents 내부 +const loader = getLoaderFn(matchActivity?.loader); + +// createBeforeRouteHandler 내부 +const loaderFn = getLoaderFn(matchActivity.loader); +const loaderData = loaderFn && resolve(loadData(activityName, activityParams)); +``` + +**Step 4: 빌드 확인** + +Run: `cd integrations/react && yarn build` +Expected: 빌드 성공 + +**Step 5: Commit** + +```bash +git add integrations/react/src/future/loader/loaderPlugin.tsx +git commit -m "$(cat <<'EOF' +feat(react): add wrapActivity to loaderPlugin for invalidation support + +- Wrap activities with ActivityLoaderProvider when loader exists +- Extract shouldInvalidate from ActivityLoaderConfig +- Update existing code to use getLoaderFn utility + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 6: useLoaderData 수정 + +**Files:** +- Modify: `integrations/react/src/future/loader/useLoaderData.ts` + +**Step 1: useLoaderData 수정** + +`integrations/react/src/future/loader/useLoaderData.ts` 파일 전체 교체: + +```typescript +import type { ActivityLoaderArgs } from "@stackflow/config"; +import { useContext } from "react"; +import { resolve } from "../../__internal__/utils/SyncInspectablePromise"; +import { useThenable } from "../../__internal__/utils/useThenable"; +import { useActivity } from "../../stable"; +import { ActivityLoaderContext } from "./ActivityLoaderContext"; + +export function useLoaderData< + T extends (args: ActivityLoaderArgs) => any, +>(): Awaited> { + const context = useContext(ActivityLoaderContext); + + // ActivityLoaderProvider가 있으면 context에서 가져옴 + if (context) { + return useThenable(resolve(context.loaderData)); + } + + // fallback: 기존 방식 (activity.context에서 직접 가져옴) + const activity = useActivity(); + return useThenable(resolve((activity.context as any)?.loaderData)); +} +``` + +**Step 2: 빌드 확인** + +Run: `cd integrations/react && yarn build` +Expected: 빌드 성공 + +**Step 3: Commit** + +```bash +git add integrations/react/src/future/loader/useLoaderData.ts +git commit -m "$(cat <<'EOF' +feat(react): update useLoaderData to use ActivityLoaderContext + +- Prefer ActivityLoaderContext when available +- Fallback to activity.context for backward compatibility +- Maintain existing API and behavior + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 7: Export 추가 + +**Files:** +- Modify: `integrations/react/src/future/loader/index.ts` +- Modify: `integrations/react/src/future/index.ts` (if needed) + +**Step 1: loader/index.ts 확인 및 수정** + +`integrations/react/src/future/loader/index.ts`에 useLoader export 추가: + +```typescript +export { useLoaderData } from "./useLoaderData"; +export { useLoader } from "./useLoader"; +``` + +**Step 2: future/index.ts에서 재export 확인** + +`integrations/react/src/future/index.ts`에서 loader exports 확인: + +```typescript +export { useLoaderData, useLoader } from "./loader"; +``` + +**Step 3: 빌드 확인** + +Run: `cd integrations/react && yarn build` +Expected: 빌드 성공 + +**Step 4: Commit** + +```bash +git add integrations/react/src/future/loader/index.ts integrations/react/src/future/index.ts +git commit -m "$(cat <<'EOF' +feat(react): export useLoader from future API + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 8: 전체 빌드 및 타입 체크 + +**Step 1: 전체 빌드** + +Run: `yarn build` +Expected: 모든 패키지 빌드 성공 + +**Step 2: 타입 체크** + +Run: `yarn typecheck` +Expected: 타입 에러 없음 + +**Step 3: 린트** + +Run: `yarn lint` +Expected: 린트 에러 없음 + +**Step 4: Commit (필요시)** + +빌드 과정에서 생성된 파일이 있다면 커밋 + +--- + +## Task 9: 테스트 작성 + +**Files:** +- Create: `integrations/react/src/future/loader/useLoader.spec.tsx` + +**Step 1: 테스트 파일 생성** + +`integrations/react/src/future/loader/useLoader.spec.tsx`: + +```typescript +import { describe, expect, it, vi } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { useLoader } from "./useLoader"; +import { ActivityLoaderContext } from "./ActivityLoaderContext"; +import { useActivity } from "../../stable"; + +// Mock dependencies +vi.mock("../../stable", () => ({ + useActivity: vi.fn(), +})); + +vi.mock("../config/useConfig", () => ({ + useConfig: vi.fn(() => ({ + activities: [ + { + name: "TestActivity", + loader: mockLoader, + }, + ], + })), +})); + +const mockLoader = vi.fn(() => Promise.resolve({ title: "Test" })); + +describe("useLoader", () => { + it("should return data and invalidate from context", () => { + const mockInvalidate = vi.fn(); + const mockLoaderData = { title: "Test Data" }; + + vi.mocked(useActivity).mockReturnValue({ + id: "test-id", + name: "TestActivity", + params: {}, + context: {}, + isActive: true, + isTop: true, + isRoot: false, + transitionState: "enter-done", + steps: [], + enteredBy: {} as any, + zIndex: 0, + }); + + function TestComponent() { + const { data, invalidate } = useLoader({ loaderFn: mockLoader }); + return ( +
+ {JSON.stringify(data)} + +
+ ); + } + + render( + + + , + ); + + expect(screen.getByTestId("data").textContent).toBe( + JSON.stringify(mockLoaderData), + ); + }); + + it("should throw error when loader does not match config", () => { + const wrongLoader = vi.fn(); + + vi.mocked(useActivity).mockReturnValue({ + id: "test-id", + name: "TestActivity", + params: {}, + context: {}, + isActive: true, + isTop: true, + isRoot: false, + transitionState: "enter-done", + steps: [], + enteredBy: {} as any, + zIndex: 0, + }); + + function TestComponent() { + useLoader({ loaderFn: wrongLoader }); + return null; + } + + expect(() => + render( + + + , + ), + ).toThrow("Loader mismatch"); + }); + + it("should throw error when used outside ActivityLoaderProvider", () => { + vi.mocked(useActivity).mockReturnValue({ + id: "test-id", + name: "TestActivity", + params: {}, + context: {}, + isActive: true, + isTop: true, + isRoot: false, + transitionState: "enter-done", + steps: [], + enteredBy: {} as any, + zIndex: 0, + }); + + function TestComponent() { + useLoader({ loaderFn: mockLoader }); + return null; + } + + expect(() => render()).toThrow( + "useLoader() must be used within an ActivityLoaderProvider", + ); + }); +}); +``` + +**Step 2: 테스트 실행** + +Run: `yarn test` +Expected: 테스트 통과 + +**Step 3: Commit** + +```bash +git add integrations/react/src/future/loader/useLoader.spec.tsx +git commit -m "$(cat <<'EOF' +test(react): add tests for useLoader hook + +- Test data and invalidate returned from context +- Test loader mismatch error +- Test error when used outside provider + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Task 10: Demo 앱에 예제 추가 (Optional) + +**Files:** +- Modify: `demo/src/activities/Article.tsx` (or create new example) + +**Step 1: Demo에서 useLoader 사용 예제 추가** + +Article.tsx에서 useLoader 사용 예제: + +```tsx +import { use } from "react"; +import { useLoader } from "@stackflow/react/future"; +import { articleLoader } from "./Article.loader"; + +export const Article = () => { + const { data, invalidate } = useLoader({ loaderFn: articleLoader }); + const resolved = use(data); + + return ( +
+

{resolved.title}

+ +
+ ); +}; +``` + +**Step 2: shouldInvalidate 사용 예제** + +stackflow.config.ts에서: + +```typescript +{ + name: "Article", + route: { path: "/articles/:articleId" }, + loader: { + fn: articleLoader, + shouldInvalidate: ({ prevActivity, currentActivity }) => { + return !prevActivity.isActive && currentActivity.isActive; + }, + }, +} +``` + +**Step 3: Demo 실행 확인** + +Run: `yarn dev` +Expected: Demo 앱 정상 동작 + +**Step 4: Commit** + +```bash +git add demo/ +git commit -m "$(cat <<'EOF' +docs(demo): add useLoader and shouldInvalidate example + +Co-Authored-By: Claude Opus 4.5 +EOF +)" +``` + +--- + +## Summary + +| Task | Description | Files | +|------|-------------|-------| +| 1 | ActivityLoaderConfig 타입 정의 | config/src/ | +| 2 | ActivityLoaderContext 생성 | integrations/react/src/future/loader/ | +| 3 | ActivityLoaderProvider 구현 | integrations/react/src/future/loader/ | +| 4 | useLoader hook 구현 | integrations/react/src/future/loader/ | +| 5 | loaderPlugin에 wrapActivity 추가 | integrations/react/src/future/loader/ | +| 6 | useLoaderData 수정 | integrations/react/src/future/loader/ | +| 7 | Export 추가 | integrations/react/src/future/ | +| 8 | 전체 빌드 및 타입 체크 | - | +| 9 | 테스트 작성 | integrations/react/src/future/loader/ | +| 10 | Demo 앱에 예제 추가 (Optional) | demo/ | From c3d2085fc247fd48859ace3f9695a0fe1cb41daa Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Fri, 6 Feb 2026 18:44:27 +0900 Subject: [PATCH 2/5] feat(config): add ActivityLoaderConfig type with shouldInvalidate support - Add ActivityLoaderConfig union type (function | object with fn + shouldInvalidate) - Add getLoaderFn and getShouldInvalidate utility functions - Update ActivityDefinition to use ActivityLoaderConfig Co-Authored-By: Claude Opus 4.5 --- config/src/ActivityDefinition.ts | 4 ++-- config/src/ActivityLoader.ts | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/config/src/ActivityDefinition.ts b/config/src/ActivityDefinition.ts index f9dd6ec52..8b395a471 100644 --- a/config/src/ActivityDefinition.ts +++ b/config/src/ActivityDefinition.ts @@ -1,9 +1,9 @@ -import type { ActivityLoader } from "./ActivityLoader"; +import type { ActivityLoaderConfig } from "./ActivityLoader"; import type { RegisteredActivityName } from "./RegisteredActivityName"; export interface ActivityDefinition< ActivityName extends RegisteredActivityName, > { name: ActivityName; - loader?: ActivityLoader; + loader?: ActivityLoaderConfig; } diff --git a/config/src/ActivityLoader.ts b/config/src/ActivityLoader.ts index 78b9239cb..24898f9e8 100644 --- a/config/src/ActivityLoader.ts +++ b/config/src/ActivityLoader.ts @@ -1,3 +1,5 @@ +import type { Activity } from "@stackflow/core"; + import type { ActivityLoaderArgs } from "./ActivityLoaderArgs"; import type { RegisteredActivityName } from "./RegisteredActivityName"; @@ -10,3 +12,38 @@ export function loader( ): ActivityLoader { return (args: ActivityLoaderArgs) => loaderFn(args); } + +export interface ActivityLoaderConfigObject< + ActivityName extends RegisteredActivityName, +> { + fn: ActivityLoader; + shouldInvalidate?: (args: { + prevActivity: Activity; + currentActivity: Activity; + }) => boolean; +} + +export type ActivityLoaderConfig = + | ActivityLoader + | ActivityLoaderConfigObject; + +export function getLoaderFn( + loaderConfig: ActivityLoaderConfig | undefined, +): ActivityLoader | undefined { + if (!loaderConfig) { + return undefined; + } + if (typeof loaderConfig === "function") { + return loaderConfig; + } + return loaderConfig.fn; +} + +export function getShouldInvalidate( + loaderConfig: ActivityLoaderConfig | undefined, +): ActivityLoaderConfigObject["shouldInvalidate"] | undefined { + if (!loaderConfig || typeof loaderConfig === "function") { + return undefined; + } + return loaderConfig.shouldInvalidate; +} From 88a6a7767eaf08556c187b285e0a7c56a04825c4 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Fri, 6 Feb 2026 18:44:34 +0900 Subject: [PATCH 3/5] feat(react): add loader data invalidation support - Create ActivityLoaderContext for per-activity loader data management - Create ActivityLoaderProvider with Core store subscription - Implement useLoader hook with runtime loader validation - Add wrapActivity to loaderPlugin for provider wrapping - Update useLoaderData to prefer context (backward compatible) - Export useLoader from future API Co-Authored-By: Claude Opus 4.5 --- .../src/__internal__/core/CoreProvider.tsx | 7 +- .../react/src/__internal__/core/index.ts | 1 + .../src/__internal__/core/useCoreSubscribe.ts | 5 ++ integrations/react/src/future/index.ts | 1 + .../future/loader/ActivityLoaderContext.tsx | 9 +++ .../future/loader/ActivityLoaderProvider.tsx | 72 +++++++++++++++++++ integrations/react/src/future/loader/index.ts | 1 + .../react/src/future/loader/loaderPlugin.tsx | 34 +++++++-- .../react/src/future/loader/useLoader.ts | 41 +++++++++++ .../react/src/future/loader/useLoaderData.ts | 12 +++- integrations/react/src/future/stackflow.tsx | 11 +-- 11 files changed, 182 insertions(+), 12 deletions(-) create mode 100644 integrations/react/src/__internal__/core/useCoreSubscribe.ts create mode 100644 integrations/react/src/future/loader/ActivityLoaderContext.tsx create mode 100644 integrations/react/src/future/loader/ActivityLoaderProvider.tsx create mode 100644 integrations/react/src/future/loader/useLoader.ts diff --git a/integrations/react/src/__internal__/core/CoreProvider.tsx b/integrations/react/src/__internal__/core/CoreProvider.tsx index 565e5b59a..52f248650 100644 --- a/integrations/react/src/__internal__/core/CoreProvider.tsx +++ b/integrations/react/src/__internal__/core/CoreProvider.tsx @@ -7,6 +7,9 @@ export const CoreActionsContext = createContext( null as any, ); export const CoreStateContext = createContext(null as any); +export const CoreSubscribeContext = createContext( + null as any, +); export interface CoreProviderProps { coreStore: CoreStore; @@ -27,7 +30,9 @@ export const CoreProvider: React.FC = ({ return ( - {children} + + {children} + ); diff --git a/integrations/react/src/__internal__/core/index.ts b/integrations/react/src/__internal__/core/index.ts index 1931d0422..0370b46d6 100644 --- a/integrations/react/src/__internal__/core/index.ts +++ b/integrations/react/src/__internal__/core/index.ts @@ -1,3 +1,4 @@ export * from "./CoreProvider"; export * from "./useCoreActions"; export * from "./useCoreState"; +export * from "./useCoreSubscribe"; diff --git a/integrations/react/src/__internal__/core/useCoreSubscribe.ts b/integrations/react/src/__internal__/core/useCoreSubscribe.ts new file mode 100644 index 000000000..6dca7e2ab --- /dev/null +++ b/integrations/react/src/__internal__/core/useCoreSubscribe.ts @@ -0,0 +1,5 @@ +import { useContext } from "react"; + +import { CoreSubscribeContext } from "./CoreProvider"; + +export const useCoreSubscribe = () => useContext(CoreSubscribeContext); diff --git a/integrations/react/src/future/index.ts b/integrations/react/src/future/index.ts index 91ce06a66..9ddb1f72b 100644 --- a/integrations/react/src/future/index.ts +++ b/integrations/react/src/future/index.ts @@ -7,6 +7,7 @@ export * from "./Actions"; export * from "./ActivityComponentType"; export * from "./lazy"; export * from "./loader/useLoaderData"; +export * from "./loader/useLoader"; export * from "./StackComponentType"; export * from "./StaticActivityComponentType"; export * from "./StepActions"; diff --git a/integrations/react/src/future/loader/ActivityLoaderContext.tsx b/integrations/react/src/future/loader/ActivityLoaderContext.tsx new file mode 100644 index 000000000..b08b59373 --- /dev/null +++ b/integrations/react/src/future/loader/ActivityLoaderContext.tsx @@ -0,0 +1,9 @@ +import { createContext } from "react"; + +export interface ActivityLoaderContextValue { + loaderData: unknown; + invalidate: () => void; +} + +export const ActivityLoaderContext = + createContext(null); diff --git a/integrations/react/src/future/loader/ActivityLoaderProvider.tsx b/integrations/react/src/future/loader/ActivityLoaderProvider.tsx new file mode 100644 index 000000000..c907021f2 --- /dev/null +++ b/integrations/react/src/future/loader/ActivityLoaderProvider.tsx @@ -0,0 +1,72 @@ +import type { Activity } from "@stackflow/core"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useCoreActions } from "../../__internal__/core/useCoreActions"; +import { useCoreSubscribe } from "../../__internal__/core/useCoreSubscribe"; +import { ActivityLoaderContext } from "./ActivityLoaderContext"; + +interface ActivityLoaderProviderProps { + activity: Activity; + initialLoaderData: unknown; + loadData: (activityName: string, activityParams: {}) => unknown; + shouldInvalidate?: (args: { + prevActivity: Activity; + currentActivity: Activity; + }) => boolean; + children: ReactNode; +} + +export function ActivityLoaderProvider({ + activity, + initialLoaderData, + loadData, + shouldInvalidate, + children, +}: ActivityLoaderProviderProps) { + const [loaderData, setLoaderData] = useState(initialLoaderData); + const actions = useCoreActions(); + const subscribe = useCoreSubscribe(); + const prevActivityRef = useRef(activity); + + const invalidate = useCallback(() => { + const newLoaderData = loadData(activity.name, activity.params); + setLoaderData(newLoaderData); + }, [activity.name, activity.params, loadData]); + + useEffect(() => { + if (!shouldInvalidate) { + return; + } + + const unsubscribe = subscribe(() => { + const stack = actions.getStack(); + const currentActivity = stack.activities.find( + (a) => a.id === activity.id, + ); + + if (!currentActivity) { + return; + } + + const prevActivity = prevActivityRef.current; + + if (shouldInvalidate({ prevActivity, currentActivity })) { + const newLoaderData = loadData( + currentActivity.name, + currentActivity.params, + ); + setLoaderData(newLoaderData); + } + + prevActivityRef.current = currentActivity; + }); + + return unsubscribe; + }, [actions, subscribe, activity.id, loadData, shouldInvalidate]); + + return ( + + {children} + + ); +} diff --git a/integrations/react/src/future/loader/index.ts b/integrations/react/src/future/loader/index.ts index 077c5f7b9..3d34e49fa 100644 --- a/integrations/react/src/future/loader/index.ts +++ b/integrations/react/src/future/loader/index.ts @@ -1,3 +1,4 @@ export * from "./DataLoaderContext"; export * from "./loaderPlugin"; export * from "./useLoaderData"; +export * from "./useLoader"; diff --git a/integrations/react/src/future/loader/loaderPlugin.tsx b/integrations/react/src/future/loader/loaderPlugin.tsx index 28d09c48a..704e64634 100644 --- a/integrations/react/src/future/loader/loaderPlugin.tsx +++ b/integrations/react/src/future/loader/loaderPlugin.tsx @@ -2,13 +2,14 @@ import type { ActivityDefinition, RegisteredActivityName, } from "@stackflow/config"; +import { getLoaderFn, getShouldInvalidate } from "@stackflow/config"; +import { ActivityLoaderProvider } from "./ActivityLoaderProvider"; import type { ActivityComponentType } from "../../__internal__/ActivityComponentType"; import type { StackflowReactPlugin } from "../../__internal__/StackflowReactPlugin"; import { getContentComponent, isStructuredActivityComponent, } from "../../__internal__/StructuredActivityComponentType"; -import { isPromiseLike } from "../../__internal__/utils/isPromiseLike"; import { inspect, PromiseStatus, @@ -28,6 +29,29 @@ export function loaderPlugin< return () => { return { key: "plugin-loader", + wrapActivity({ activity }) { + const matchActivity = input.config.activities.find( + (a) => a.name === activity.name, + ); + + if (!matchActivity?.loader) { + return <>{activity.render()}; + } + + const shouldInvalidate = getShouldInvalidate(matchActivity.loader); + const initialLoaderData = (activity.context as any)?.loaderData; + + return ( + + {activity.render()} + + ); + }, overrideInitialEvents({ initialEvents, initialContext }) { if (initialEvents.length === 0) { return []; @@ -54,9 +78,9 @@ export function loaderPlugin< (activity) => activity.name === activityName, ); - const loader = matchActivity?.loader; + const loader = getLoaderFn(matchActivity?.loader); - if (!loader) { + if (!loader || !matchActivity) { return event; } @@ -112,8 +136,8 @@ function createBeforeRouteHandler< return; } - const loaderData = - matchActivity.loader && resolve(loadData(activityName, activityParams)); + const loaderFn = getLoaderFn(matchActivity.loader); + const loaderData = loaderFn && resolve(loadData(activityName, activityParams)); const lazyComponentPromise = resolve( isStructuredActivityComponent(matchActivityComponent) && typeof matchActivityComponent.content === "function" diff --git a/integrations/react/src/future/loader/useLoader.ts b/integrations/react/src/future/loader/useLoader.ts new file mode 100644 index 000000000..f05e7ff23 --- /dev/null +++ b/integrations/react/src/future/loader/useLoader.ts @@ -0,0 +1,41 @@ +import { useContext } from "react"; +import { useActivity } from "../../stable"; +import { ActivityLoaderContext } from "./ActivityLoaderContext"; +import { useConfig } from "../useConfig"; +import { getLoaderFn } from "@stackflow/config"; + +export function useLoader any>(options: { + loaderFn: T; +}): { + data: ReturnType; + invalidate: () => void; +} { + const activity = useActivity(); + const config = useConfig(); + const context = useContext(ActivityLoaderContext); + + if (!context) { + throw new Error( + "useLoader() must be used within an ActivityLoaderProvider. " + + "Make sure you are using the loaderPlugin.", + ); + } + + // Runtime validation: check if the provided loader matches the config + const activityConfig = config.activities.find( + (a) => a.name === activity.name, + ); + const configLoaderFn = getLoaderFn(activityConfig?.loader); + + if (options.loaderFn !== configLoaderFn) { + throw new Error( + `Loader mismatch: the provided loader does not match the loader ` + + `registered for "${activity.name}" activity in the config.`, + ); + } + + return { + data: context.loaderData as ReturnType, + invalidate: context.invalidate, + }; +} diff --git a/integrations/react/src/future/loader/useLoaderData.ts b/integrations/react/src/future/loader/useLoaderData.ts index bbe85e64b..7ed456def 100644 --- a/integrations/react/src/future/loader/useLoaderData.ts +++ b/integrations/react/src/future/loader/useLoaderData.ts @@ -1,10 +1,20 @@ import type { ActivityLoaderArgs } from "@stackflow/config"; +import { useContext } from "react"; import { resolve } from "../../__internal__/utils/SyncInspectablePromise"; import { useThenable } from "../../__internal__/utils/useThenable"; import { useActivity } from "../../stable"; +import { ActivityLoaderContext } from "./ActivityLoaderContext"; export function useLoaderData< T extends (args: ActivityLoaderArgs) => any, >(): Awaited> { - return useThenable(resolve((useActivity().context as any)?.loaderData)); + const context = useContext(ActivityLoaderContext); + const activity = useActivity(); + + // ActivityLoaderProvider가 있으면 context에서 가져옴, 없으면 activity.context에서 가져옴 + const loaderData = context + ? context.loaderData + : (activity.context as any)?.loaderData; + + return useThenable(resolve(loaderData)); } diff --git a/integrations/react/src/future/stackflow.tsx b/integrations/react/src/future/stackflow.tsx index 02bf8a5d1..80a34eb11 100644 --- a/integrations/react/src/future/stackflow.tsx +++ b/integrations/react/src/future/stackflow.tsx @@ -5,6 +5,7 @@ import type { InferActivityParams, RegisteredActivityName, } from "@stackflow/config"; +import { getLoaderFn } from "@stackflow/config"; import { type CoreStore, makeCoreStore, @@ -66,12 +67,12 @@ export function stackflow< throw new Error(`Activity ${activityName} is not registered.`); } - const loaderData = activityConfig.loader?.({ - params: activityParams, - config: input.config, - }); + const loader = getLoaderFn(activityConfig.loader); + if (loader) { + return loader({ params: activityParams, config: input.config }); + } - return loaderData; + return undefined; }; const plugins = [ ...(input.plugins ?? []) From 6d7f40705f43342b28dd3f943a4c9113dc33ee40 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Fri, 6 Feb 2026 18:44:38 +0900 Subject: [PATCH 4/5] test(config): add tests for getLoaderFn and getShouldInvalidate utilities Co-Authored-By: Claude Opus 4.5 --- .pnp.cjs | 232 ++++++++++++++++++++++++++---- config/package.json | 17 +++ config/src/ActivityLoader.spec.ts | 62 ++++++++ yarn.lock | 7 + 4 files changed, 291 insertions(+), 27 deletions(-) create mode 100644 config/src/ActivityLoader.spec.ts diff --git a/.pnp.cjs b/.pnp.cjs index 99276d843..f2896fc15 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -91,24 +91,24 @@ const RAW_RUNTIME_STATE = "ignorePatternData": "(^(?:\\\\.yarn\\\\/sdks(?:\\\\/(?!\\\\.{1,2}(?:\\\\/|$))(?:(?:(?!(?:^|\\\\/)\\\\.{1,2}(?:\\\\/|$)).)*?)|$))$)",\ "fallbackExclusionList": [\ ["@stackflow/compat-await-push", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/compat-await-push", "workspace:extensions/compat-await-push"]],\ - ["@stackflow/config", ["workspace:config"]],\ + ["@stackflow/config", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config", "workspace:config"]],\ ["@stackflow/core", ["workspace:core"]],\ ["@stackflow/demo", ["workspace:demo"]],\ ["@stackflow/docs", ["workspace:docs"]],\ ["@stackflow/esbuild-config", ["workspace:packages/esbuild-config"]],\ ["@stackflow/link", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/link", "workspace:extensions/link"]],\ ["@stackflow/monorepo", ["workspace:."]],\ - ["@stackflow/plugin-basic-ui", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-basic-ui", "workspace:extensions/plugin-basic-ui"]],\ + ["@stackflow/plugin-basic-ui", ["virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-basic-ui", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-basic-ui", "workspace:extensions/plugin-basic-ui"]],\ ["@stackflow/plugin-devtools", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-devtools", "workspace:extensions/plugin-devtools"]],\ ["@stackflow/plugin-google-analytics-4", ["workspace:extensions/plugin-google-analytics-4"]],\ - ["@stackflow/plugin-history-sync", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync", "workspace:extensions/plugin-history-sync"]],\ + ["@stackflow/plugin-history-sync", ["virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-history-sync", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync", "workspace:extensions/plugin-history-sync"]],\ ["@stackflow/plugin-map-initial-activity", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-map-initial-activity", "workspace:extensions/plugin-map-initial-activity"]],\ ["@stackflow/plugin-preload", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-preload", "workspace:extensions/plugin-preload"]],\ - ["@stackflow/plugin-renderer-basic", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic", "workspace:extensions/plugin-renderer-basic"]],\ + ["@stackflow/plugin-renderer-basic", ["virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-renderer-basic", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic", "workspace:extensions/plugin-renderer-basic"]],\ ["@stackflow/plugin-renderer-web", ["workspace:extensions/plugin-renderer-web"]],\ ["@stackflow/plugin-stack-depth-change", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-stack-depth-change", "workspace:extensions/plugin-stack-depth-change"]],\ - ["@stackflow/react", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react", "workspace:integrations/react"]],\ - ["@stackflow/react-ui-core", ["virtual:669046a185e83900af978519e5adddf8e8f1f8fed824849248ba56cf8fcd4e4208872f27e14c3c844d3b769f42be1ba6e0aa90f12df9fa6c38a55aedee211f53#workspace:extensions/react-ui-core", "workspace:extensions/react-ui-core"]]\ + ["@stackflow/react", ["virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react", "workspace:integrations/react"]],\ + ["@stackflow/react-ui-core", ["virtual:669046a185e83900af978519e5adddf8e8f1f8fed824849248ba56cf8fcd4e4208872f27e14c3c844d3b769f42be1ba6e0aa90f12df9fa6c38a55aedee211f53#workspace:extensions/react-ui-core", "virtual:9506e63a437e20118ec53e35394f44ac597a1e19dd190b4d073d27922774bba693971575adba8977670ff1bb425f29ad6779506df7c002f3a95d17880d69dfb6#workspace:extensions/react-ui-core", "workspace:extensions/react-ui-core"]]\ ],\ "fallbackPool": [\ ],\ @@ -6264,7 +6264,7 @@ const RAW_RUNTIME_STATE = ["@stackflow/compat-await-push", "workspace:extensions/compat-await-push"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.23.0"],\ ["react", "npm:18.3.1"],\ @@ -6275,12 +6275,39 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@stackflow/config", [\ + ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config", {\ + "packageLocation": "./.yarn/__virtual__/@stackflow-config-virtual-5818873c19/1/config/",\ + "packageDependencies": [\ + ["@stackflow/config", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@swc/core", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:1.6.6"],\ + ["@swc/jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:0.2.36"],\ + ["@types/jest", "npm:29.5.12"],\ + ["@types/stackflow__core", null],\ + ["esbuild", "npm:0.23.0"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ + ["rimraf", "npm:3.0.2"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"],\ + ["ultra-runner", "npm:3.10.5"]\ + ],\ + "packagePeers": [\ + "@stackflow/core",\ + "@types/stackflow__core"\ + ],\ + "linkType": "SOFT"\ + }],\ ["workspace:config", {\ "packageLocation": "./config/",\ "packageDependencies": [\ ["@stackflow/config", "workspace:config"],\ + ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@swc/core", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:1.6.6"],\ + ["@swc/jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:0.2.36"],\ + ["@types/jest", "npm:29.5.12"],\ ["esbuild", "npm:0.23.0"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ ["rimraf", "npm:3.0.2"],\ ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"],\ ["ultra-runner", "npm:3.10.5"]\ @@ -6316,7 +6343,7 @@ const RAW_RUNTIME_STATE = ["@seed-design/design-token", "npm:1.0.3"],\ ["@seed-design/stylesheet", "npm:1.0.4"],\ ["@stackflow/compat-await-push", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/compat-await-push"],\ - ["@stackflow/config", "workspace:config"],\ + ["@stackflow/config", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ ["@stackflow/link", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/link"],\ @@ -6367,10 +6394,10 @@ const RAW_RUNTIME_STATE = ["@seed-design/stylesheet", "npm:1.0.4"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/demo", "workspace:demo"],\ - ["@stackflow/plugin-basic-ui", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-basic-ui"],\ - ["@stackflow/plugin-history-sync", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync"],\ - ["@stackflow/plugin-renderer-basic", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic"],\ - ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ + ["@stackflow/plugin-basic-ui", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-basic-ui"],\ + ["@stackflow/plugin-history-sync", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-history-sync"],\ + ["@stackflow/plugin-renderer-basic", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-renderer-basic"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ ["@types/node", "npm:22.7.5"],\ ["@types/react", "npm:18.3.3"],\ ["autoprefixer", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#npm:10.4.20"],\ @@ -6413,7 +6440,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./.yarn/__virtual__/@stackflow-link-virtual-9ebc34fcb6/1/extensions/link/",\ "packageDependencies": [\ ["@stackflow/link", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/link"],\ - ["@stackflow/config", "workspace:config"],\ + ["@stackflow/config", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ ["@stackflow/plugin-history-sync", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync"],\ @@ -6448,7 +6475,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./extensions/link/",\ "packageDependencies": [\ ["@stackflow/link", "workspace:extensions/link"],\ - ["@stackflow/config", "workspace:config"],\ + ["@stackflow/config", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ ["@stackflow/plugin-history-sync", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync"],\ @@ -6481,6 +6508,37 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@stackflow/plugin-basic-ui", [\ + ["virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-basic-ui", {\ + "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-basic-ui-virtual-9506e63a43/1/extensions/plugin-basic-ui/",\ + "packageDependencies": [\ + ["@stackflow/plugin-basic-ui", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-basic-ui"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ + ["@stackflow/react-ui-core", "virtual:9506e63a437e20118ec53e35394f44ac597a1e19dd190b4d073d27922774bba693971575adba8977670ff1bb425f29ad6779506df7c002f3a95d17880d69dfb6#workspace:extensions/react-ui-core"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/stackflow__core", null],\ + ["@types/stackflow__react", null],\ + ["@vanilla-extract/css", "npm:1.15.3"],\ + ["@vanilla-extract/dynamic", "npm:2.1.1"],\ + ["@vanilla-extract/private", "npm:1.0.5"],\ + ["@vanilla-extract/recipes", "virtual:669046a185e83900af978519e5adddf8e8f1f8fed824849248ba56cf8fcd4e4208872f27e14c3c844d3b769f42be1ba6e0aa90f12df9fa6c38a55aedee211f53#npm:0.5.3"],\ + ["clsx", "npm:2.1.1"],\ + ["esbuild", "npm:0.23.0"],\ + ["react", "npm:18.3.1"],\ + ["rimraf", "npm:3.0.2"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ + ],\ + "packagePeers": [\ + "@stackflow/core",\ + "@stackflow/react",\ + "@types/react",\ + "@types/stackflow__core",\ + "@types/stackflow__react",\ + "react"\ + ],\ + "linkType": "SOFT"\ + }],\ ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-basic-ui", {\ "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-basic-ui-virtual-669046a185/1/extensions/plugin-basic-ui/",\ "packageDependencies": [\ @@ -6518,8 +6576,8 @@ const RAW_RUNTIME_STATE = ["@stackflow/plugin-basic-ui", "workspace:extensions/plugin-basic-ui"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ - ["@stackflow/react-ui-core", "virtual:669046a185e83900af978519e5adddf8e8f1f8fed824849248ba56cf8fcd4e4208872f27e14c3c844d3b769f42be1ba6e0aa90f12df9fa6c38a55aedee211f53#workspace:extensions/react-ui-core"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ + ["@stackflow/react-ui-core", "virtual:9506e63a437e20118ec53e35394f44ac597a1e19dd190b4d073d27922774bba693971575adba8977670ff1bb425f29ad6779506df7c002f3a95d17880d69dfb6#workspace:extensions/react-ui-core"],\ ["@types/react", "npm:18.3.3"],\ ["@vanilla-extract/css", "npm:1.15.3"],\ ["@vanilla-extract/dynamic", "npm:2.1.1"],\ @@ -6574,7 +6632,7 @@ const RAW_RUNTIME_STATE = ["@stackflow/plugin-google-analytics-4", "workspace:extensions/plugin-google-analytics-4"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.23.0"],\ ["react", "npm:18.3.1"],\ @@ -6586,12 +6644,56 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@stackflow/plugin-history-sync", [\ + ["virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-history-sync", {\ + "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-history-sync-virtual-f6cfb0d7c2/1/extensions/plugin-history-sync/",\ + "packageDependencies": [\ + ["@stackflow/plugin-history-sync", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-history-sync"],\ + ["@graphql-tools/schema", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:10.0.5"],\ + ["@stackflow/config", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ + ["@swc/core", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:1.6.6"],\ + ["@swc/jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:0.2.36"],\ + ["@types/jest", "npm:29.5.12"],\ + ["@types/node", "npm:20.14.9"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/react-relay", "npm:16.0.6"],\ + ["@types/relay-runtime", "npm:17.0.4"],\ + ["@types/stackflow__config", null],\ + ["@types/stackflow__core", null],\ + ["@types/stackflow__react", null],\ + ["esbuild", "npm:0.23.0"],\ + ["flatted", "npm:3.3.1"],\ + ["graphql", "npm:16.9.0"],\ + ["history", "npm:5.3.0"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ + ["react", "npm:18.3.1"],\ + ["react-relay", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:17.0.0"],\ + ["react18-use", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:0.4.1"],\ + ["relay-compiler", "npm:17.0.0"],\ + ["relay-runtime", "npm:17.0.0"],\ + ["rimraf", "npm:3.0.2"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"],\ + ["url-pattern", "npm:1.0.3"]\ + ],\ + "packagePeers": [\ + "@stackflow/core",\ + "@stackflow/react",\ + "@types/react",\ + "@types/stackflow__config",\ + "@types/stackflow__core",\ + "@types/stackflow__react",\ + "react"\ + ],\ + "linkType": "SOFT"\ + }],\ ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync", {\ "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-history-sync-virtual-991015ceb8/1/extensions/plugin-history-sync/",\ "packageDependencies": [\ ["@stackflow/plugin-history-sync", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync"],\ ["@graphql-tools/schema", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:10.0.5"],\ - ["@stackflow/config", "workspace:config"],\ + ["@stackflow/config", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ @@ -6636,7 +6738,7 @@ const RAW_RUNTIME_STATE = "packageDependencies": [\ ["@stackflow/plugin-history-sync", "workspace:extensions/plugin-history-sync"],\ ["@graphql-tools/schema", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:10.0.5"],\ - ["@stackflow/config", "workspace:config"],\ + ["@stackflow/config", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ @@ -6692,7 +6794,7 @@ const RAW_RUNTIME_STATE = ["@stackflow/plugin-map-initial-activity", "workspace:extensions/plugin-map-initial-activity"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ ["esbuild", "npm:0.23.0"],\ ["rimraf", "npm:3.0.2"],\ ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ @@ -6736,8 +6838,8 @@ const RAW_RUNTIME_STATE = ["@stackflow/plugin-preload", "workspace:extensions/plugin-preload"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/plugin-history-sync", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync"],\ - ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ + ["@stackflow/plugin-history-sync", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-history-sync"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.23.0"],\ ["react", "npm:18.3.1"],\ @@ -6748,6 +6850,31 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@stackflow/plugin-renderer-basic", [\ + ["virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-renderer-basic", {\ + "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-renderer-basic-virtual-9a88d3ff8e/1/extensions/plugin-renderer-basic/",\ + "packageDependencies": [\ + ["@stackflow/plugin-renderer-basic", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:extensions/plugin-renderer-basic"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/stackflow__core", null],\ + ["@types/stackflow__react", null],\ + ["esbuild", "npm:0.23.0"],\ + ["react", "npm:18.3.1"],\ + ["rimraf", "npm:3.0.2"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ + ],\ + "packagePeers": [\ + "@stackflow/core",\ + "@stackflow/react",\ + "@types/react",\ + "@types/stackflow__core",\ + "@types/stackflow__react",\ + "react"\ + ],\ + "linkType": "SOFT"\ + }],\ ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic", {\ "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-renderer-basic-virtual-84c5c2a317/1/extensions/plugin-renderer-basic/",\ "packageDependencies": [\ @@ -6779,7 +6906,7 @@ const RAW_RUNTIME_STATE = ["@stackflow/plugin-renderer-basic", "workspace:extensions/plugin-renderer-basic"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.23.0"],\ ["react", "npm:18.3.1"],\ @@ -6796,7 +6923,7 @@ const RAW_RUNTIME_STATE = ["@stackflow/plugin-renderer-web", "workspace:extensions/plugin-renderer-web"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.23.0"],\ ["react", "npm:18.3.1"],\ @@ -6840,11 +6967,37 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@stackflow/react", [\ + ["virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react", {\ + "packageLocation": "./.yarn/__virtual__/@stackflow-react-virtual-200e66984e/1/integrations/react/",\ + "packageDependencies": [\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ + ["@stackflow/config", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/stackflow__config", null],\ + ["@types/stackflow__core", null],\ + ["esbuild", "npm:0.23.0"],\ + ["esbuild-plugin-file-path-extensions", "npm:2.1.3"],\ + ["react", "npm:18.3.1"],\ + ["react-fast-compare", "npm:3.2.2"],\ + ["rimraf", "npm:3.0.2"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ + ],\ + "packagePeers": [\ + "@stackflow/core",\ + "@types/react",\ + "@types/stackflow__config",\ + "@types/stackflow__core",\ + "react"\ + ],\ + "linkType": "SOFT"\ + }],\ ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react", {\ "packageLocation": "./.yarn/__virtual__/@stackflow-react-virtual-eeae00ab9c/1/integrations/react/",\ "packageDependencies": [\ ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ - ["@stackflow/config", "workspace:config"],\ + ["@stackflow/config", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ ["@types/react", "npm:18.3.3"],\ @@ -6871,7 +7024,7 @@ const RAW_RUNTIME_STATE = "packageLocation": "./integrations/react/",\ "packageDependencies": [\ ["@stackflow/react", "workspace:integrations/react"],\ - ["@stackflow/config", "workspace:config"],\ + ["@stackflow/config", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ ["@types/react", "npm:18.3.3"],\ @@ -6911,13 +7064,38 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ + ["virtual:9506e63a437e20118ec53e35394f44ac597a1e19dd190b4d073d27922774bba693971575adba8977670ff1bb425f29ad6779506df7c002f3a95d17880d69dfb6#workspace:extensions/react-ui-core", {\ + "packageLocation": "./.yarn/__virtual__/@stackflow-react-ui-core-virtual-dd722669ce/1/extensions/react-ui-core/",\ + "packageDependencies": [\ + ["@stackflow/react-ui-core", "virtual:9506e63a437e20118ec53e35394f44ac597a1e19dd190b4d073d27922774bba693971575adba8977670ff1bb425f29ad6779506df7c002f3a95d17880d69dfb6#workspace:extensions/react-ui-core"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/stackflow__core", null],\ + ["@types/stackflow__react", null],\ + ["esbuild", "npm:0.23.0"],\ + ["react", "npm:18.3.1"],\ + ["rimraf", "npm:3.0.2"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ + ],\ + "packagePeers": [\ + "@stackflow/core",\ + "@stackflow/react",\ + "@types/react",\ + "@types/stackflow__core",\ + "@types/stackflow__react",\ + "react"\ + ],\ + "linkType": "SOFT"\ + }],\ ["workspace:extensions/react-ui-core", {\ "packageLocation": "./extensions/react-ui-core/",\ "packageDependencies": [\ ["@stackflow/react-ui-core", "workspace:extensions/react-ui-core"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ - ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ + ["@stackflow/react", "virtual:0349e4c3e3c13e6c2c3644c139b8cc36986a13e674699ef552f832f4811603d25720461dd0f421afb36637b7bef854e2b3f31dfc5b707238429c2469f85316ac#workspace:integrations/react"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.23.0"],\ ["react", "npm:18.3.1"],\ diff --git a/config/package.json b/config/package.json index 2f854d94b..bee02aeae 100644 --- a/config/package.json +++ b/config/package.json @@ -28,15 +28,32 @@ "build:js": "node ./esbuild.config.js", "clean": "rimraf dist", "dev": "yarn build:js --watch && yarn build:dts --watch", + "test": "yarn jest", "typecheck": "tsc --noEmit" }, + "jest": { + "coveragePathIgnorePatterns": [ + "index.ts" + ], + "transform": { + "^.+\\.(t|j)sx?$": "@swc/jest" + } + }, "devDependencies": { + "@stackflow/core": "^1.3.0", "@stackflow/esbuild-config": "^1.0.3", + "@swc/core": "^1.6.6", + "@swc/jest": "^0.2.36", + "@types/jest": "^29.5.12", "esbuild": "^0.23.0", + "jest": "^29.7.0", "rimraf": "^3.0.2", "typescript": "^5.5.3", "ultra-runner": "^3.10.5" }, + "peerDependencies": { + "@stackflow/core": "^1.1.0-canary.0" + }, "publishConfig": { "access": "public" }, diff --git a/config/src/ActivityLoader.spec.ts b/config/src/ActivityLoader.spec.ts new file mode 100644 index 000000000..c32177089 --- /dev/null +++ b/config/src/ActivityLoader.spec.ts @@ -0,0 +1,62 @@ +import type { Activity } from "@stackflow/core"; +import { getLoaderFn, getShouldInvalidate } from "./ActivityLoader"; + +describe("getLoaderFn", () => { + it("should return undefined when loaderConfig is undefined", () => { + expect(getLoaderFn(undefined)).toBeUndefined(); + }); + + it("should return the function itself when loaderConfig is a function", () => { + const loaderFn = () => Promise.resolve({ data: "test" }); + expect(getLoaderFn(loaderFn)).toBe(loaderFn); + }); + + it("should return the fn property when loaderConfig is an object", () => { + const loaderFn = () => Promise.resolve({ data: "test" }); + const loaderConfig = { + fn: loaderFn, + shouldInvalidate: () => true, + }; + expect(getLoaderFn(loaderConfig)).toBe(loaderFn); + }); + + it("should return the fn property when loaderConfig object has no shouldInvalidate", () => { + const loaderFn = () => Promise.resolve({ data: "test" }); + const loaderConfig = { fn: loaderFn }; + expect(getLoaderFn(loaderConfig)).toBe(loaderFn); + }); +}); + +describe("getShouldInvalidate", () => { + it("should return undefined when loaderConfig is undefined", () => { + expect(getShouldInvalidate(undefined)).toBeUndefined(); + }); + + it("should return undefined when loaderConfig is a function", () => { + const loaderFn = () => Promise.resolve({ data: "test" }); + expect(getShouldInvalidate(loaderFn)).toBeUndefined(); + }); + + it("should return the shouldInvalidate function when loaderConfig is an object", () => { + const shouldInvalidateFn = ({ + prevActivity, + currentActivity, + }: { + prevActivity: Activity; + currentActivity: Activity; + }) => !prevActivity.isActive && currentActivity.isActive; + + const loaderConfig = { + fn: () => Promise.resolve({ data: "test" }), + shouldInvalidate: shouldInvalidateFn, + }; + expect(getShouldInvalidate(loaderConfig)).toBe(shouldInvalidateFn); + }); + + it("should return undefined when loaderConfig object has no shouldInvalidate", () => { + const loaderConfig = { + fn: () => Promise.resolve({ data: "test" }), + }; + expect(getShouldInvalidate(loaderConfig)).toBeUndefined(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 688a2baa5..669108321 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5355,11 +5355,18 @@ __metadata: version: 0.0.0-use.local resolution: "@stackflow/config@workspace:config" dependencies: + "@stackflow/core": "npm:^1.3.0" "@stackflow/esbuild-config": "npm:^1.0.3" + "@swc/core": "npm:^1.6.6" + "@swc/jest": "npm:^0.2.36" + "@types/jest": "npm:^29.5.12" esbuild: "npm:^0.23.0" + jest: "npm:^29.7.0" rimraf: "npm:^3.0.2" typescript: "npm:^5.5.3" ultra-runner: "npm:^3.10.5" + peerDependencies: + "@stackflow/core": ^1.1.0-canary.0 languageName: unknown linkType: soft From b7658c404b38f5f9fa02cddef87eed895a6523ee Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Fri, 6 Feb 2026 18:50:00 +0900 Subject: [PATCH 5/5] feat(config): extend loader function to support shouldInvalidate option - Add LoaderOptions interface with shouldInvalidate - Add function overloads for loader() - Return ActivityLoaderConfigObject when options provided - Add tests for loader function Co-Authored-By: Claude Opus 4.5 --- config/src/ActivityLoader.spec.ts | 38 ++++++++++++++++++++++++++++++- config/src/ActivityLoader.ts | 27 ++++++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/config/src/ActivityLoader.spec.ts b/config/src/ActivityLoader.spec.ts index c32177089..785760d8c 100644 --- a/config/src/ActivityLoader.spec.ts +++ b/config/src/ActivityLoader.spec.ts @@ -1,5 +1,5 @@ import type { Activity } from "@stackflow/core"; -import { getLoaderFn, getShouldInvalidate } from "./ActivityLoader"; +import { getLoaderFn, getShouldInvalidate, loader } from "./ActivityLoader"; describe("getLoaderFn", () => { it("should return undefined when loaderConfig is undefined", () => { @@ -60,3 +60,39 @@ describe("getShouldInvalidate", () => { expect(getShouldInvalidate(loaderConfig)).toBeUndefined(); }); }); + +describe("loader", () => { + it("should return the function directly when no options provided", () => { + const loaderFn = () => Promise.resolve({ data: "test" }); + const result = loader(loaderFn); + expect(result).toBe(loaderFn); + }); + + it("should return ActivityLoaderConfigObject when options provided", () => { + const loaderFn = () => Promise.resolve({ data: "test" }); + const shouldInvalidateFn = ({ + prevActivity, + currentActivity, + }: { + prevActivity: Activity; + currentActivity: Activity; + }) => !prevActivity.isActive && currentActivity.isActive; + + const result = loader(loaderFn, { shouldInvalidate: shouldInvalidateFn }); + + expect(result).toEqual({ + fn: loaderFn, + shouldInvalidate: shouldInvalidateFn, + }); + }); + + it("should work with getLoaderFn and getShouldInvalidate", () => { + const loaderFn = () => Promise.resolve({ data: "test" }); + const shouldInvalidateFn = () => true; + + const config = loader(loaderFn, { shouldInvalidate: shouldInvalidateFn }); + + expect(getLoaderFn(config)).toBe(loaderFn); + expect(getShouldInvalidate(config)).toBe(shouldInvalidateFn); + }); +}); diff --git a/config/src/ActivityLoader.ts b/config/src/ActivityLoader.ts index 24898f9e8..085b5982c 100644 --- a/config/src/ActivityLoader.ts +++ b/config/src/ActivityLoader.ts @@ -7,10 +7,33 @@ export type ActivityLoader = ( args: ActivityLoaderArgs, ) => any; +export interface LoaderOptions { + shouldInvalidate?: (args: { + prevActivity: Activity; + currentActivity: Activity; + }) => boolean; +} + export function loader( loaderFn: (args: ActivityLoaderArgs) => any, -): ActivityLoader { - return (args: ActivityLoaderArgs) => loaderFn(args); +): ActivityLoader; + +export function loader( + loaderFn: (args: ActivityLoaderArgs) => any, + options: LoaderOptions, +): ActivityLoaderConfigObject; + +export function loader( + loaderFn: (args: ActivityLoaderArgs) => any, + options?: LoaderOptions, +): ActivityLoader | ActivityLoaderConfigObject { + if (options) { + return { + fn: loaderFn, + shouldInvalidate: options.shouldInvalidate, + }; + } + return loaderFn; } export interface ActivityLoaderConfigObject<