From 8fad69ae19a7b5054bd1fdc463afb704a6390240 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 4 Jun 2026 15:26:34 +0900 Subject: [PATCH 1/6] test(react): set up Jest test harness for @stackflow/react (FEP-2357) - Add Jest + @swc/jest + jsdom + Testing Library to integrations/react, following the inline-config convention of plugin-blocker/plugin-history-sync - Exclude *.spec.* files from esbuild/dts build output; add tsconfig.test.json so `yarn typecheck` covers spec files (incl. Register augmentations) - Type-only cast in PluginRenderer so library source stays type-checkable when specs augment the Register interface - Add a harness smoke spec using an inline renderer plugin (public render API) to avoid a workspace dependency cycle with plugin-renderer-basic Co-Authored-By: Claude Opus 4.8 (1M context) --- .pnp.cjs | 18 +++++ integrations/react/esbuild.config.js | 24 ++++++- integrations/react/package.json | 32 ++++++++- integrations/react/src/PluginRenderer.tsx | 7 +- integrations/react/src/harness.smoke.spec.tsx | 65 +++++++++++++++++++ integrations/react/tsconfig.json | 2 +- integrations/react/tsconfig.test.json | 7 ++ yarn.lock | 9 +++ 8 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 integrations/react/src/harness.smoke.spec.tsx create mode 100644 integrations/react/tsconfig.test.json diff --git a/.pnp.cjs b/.pnp.cjs index 6a194ab38..bdb5a9b96 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -7077,12 +7077,21 @@ const RAW_RUNTIME_STATE = ["@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"],\ + ["@testing-library/dom", "npm:10.4.1"],\ + ["@testing-library/react", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:16.3.2"],\ + ["@types/jest", "npm:29.5.12"],\ ["@types/react", "npm:18.3.3"],\ + ["@types/react-dom", "npm:18.3.0"],\ ["@types/stackflow__config", null],\ ["@types/stackflow__core", null],\ ["esbuild", "npm:0.23.0"],\ ["esbuild-plugin-file-path-extensions", "npm:2.1.3"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:29.7.0"],\ ["react", "npm:18.3.1"],\ + ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#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"]\ @@ -7104,10 +7113,19 @@ const RAW_RUNTIME_STATE = ["@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"],\ + ["@testing-library/dom", "npm:10.4.1"],\ + ["@testing-library/react", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:16.3.2"],\ + ["@types/jest", "npm:29.5.12"],\ ["@types/react", "npm:18.3.3"],\ + ["@types/react-dom", "npm:18.3.0"],\ ["esbuild", "npm:0.23.0"],\ ["esbuild-plugin-file-path-extensions", "npm:2.1.3"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:29.7.0"],\ ["react", "npm:18.3.1"],\ + ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#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"]\ diff --git a/integrations/react/esbuild.config.js b/integrations/react/esbuild.config.js index 17749d586..4aa5882b8 100644 --- a/integrations/react/esbuild.config.js +++ b/integrations/react/esbuild.config.js @@ -1,3 +1,5 @@ +const fs = require("node:fs"); +const path = require("node:path"); const { context } = require("esbuild"); const config = require("@stackflow/esbuild-config"); const { @@ -12,10 +14,28 @@ const external = Object.keys({ ...pkg.peerDependencies, }); +/** + * Equivalent to the `./src/**\/*` glob, except that test files (`*.spec.*`) + * are excluded from the build output. + */ +function listEntryPoints(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + return listEntryPoints(fullPath); + } + + return entry.name.includes(".spec.") ? [] : [fullPath]; + }); +} + +const entryPoints = listEntryPoints("./src"); + Promise.all([ context({ ...config({ - entryPoints: ["./src/**/*"], + entryPoints, outdir: "dist", }), bundle: false, @@ -27,7 +47,7 @@ Promise.all([ ), context({ ...config({ - entryPoints: ["./src/**/*"], + entryPoints, outdir: "dist", }), bundle: true, diff --git a/integrations/react/package.json b/integrations/react/package.json index 3182f181d..f3cfa8c55 100644 --- a/integrations/react/package.json +++ b/integrations/react/package.json @@ -28,7 +28,28 @@ "build:js": "node ./esbuild.config.js", "clean": "rimraf dist", "dev": "yarn build:js --watch && yarn build:dts --watch", - "typecheck": "tsc --noEmit" + "test": "yarn jest", + "typecheck": "tsc --noEmit -p ./tsconfig.test.json" + }, + "jest": { + "testEnvironment": "jsdom", + "coveragePathIgnorePatterns": [ + "index.ts" + ], + "transform": { + "^.+\\.(t|j)sx?$": [ + "@swc/jest", + { + "jsc": { + "transform": { + "react": { + "runtime": "automatic" + } + } + } + } + ] + } }, "dependencies": { "react-fast-compare": "^3.2.2" @@ -37,10 +58,19 @@ "@stackflow/config": "^2.0.0", "@stackflow/core": "^2.0.0", "@stackflow/esbuild-config": "^1.0.3", + "@swc/core": "^1.6.6", + "@swc/jest": "^0.2.36", + "@testing-library/dom": "^10.4.0", + "@testing-library/react": "^16.3.2", + "@types/jest": "^29.5.12", "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", "esbuild": "^0.23.0", "esbuild-plugin-file-path-extensions": "^2.1.2", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "react": "^18.3.1", + "react-dom": "^18.3.1", "rimraf": "^3.0.2", "typescript": "^5.5.3" }, diff --git a/integrations/react/src/PluginRenderer.tsx b/integrations/react/src/PluginRenderer.tsx index da3eb8541..2da7779f4 100644 --- a/integrations/react/src/PluginRenderer.tsx +++ b/integrations/react/src/PluginRenderer.tsx @@ -1,3 +1,4 @@ +import type { RegisteredActivityName } from "@stackflow/config"; import React, { Component, type ReactNode, Suspense } from "react"; import { useActivityComponentMap } from "./ActivityComponentMapProvider"; import { ActivityProvider } from "./activity"; @@ -37,7 +38,11 @@ const PluginRenderer: React.FC = ({ ...activity, key: activity.id, render(overrideActivity) { - const Activity = activityComponentMap[activity.name]; + // `activity.name` is a plain `string` at the core level, while the + // component map is keyed by `RegisteredActivityName`. The cast keeps + // this file type-checkable when `Register` is augmented (e.g. in specs). + const Activity = + activityComponentMap[activity.name as RegisteredActivityName]; let output: React.ReactNode = isStructuredActivityComponent( Activity, diff --git a/integrations/react/src/harness.smoke.spec.tsx b/integrations/react/src/harness.smoke.spec.tsx new file mode 100644 index 000000000..6df3a8f56 --- /dev/null +++ b/integrations/react/src/harness.smoke.spec.tsx @@ -0,0 +1,65 @@ +/** + * Smoke test that verifies the test harness itself: + * + * - `.spec.tsx` files are picked up by Jest and transformed by `@swc/jest` + * - the `jsdom` environment and `@testing-library/react` work together + * - workspace dependencies (`@stackflow/config`, `@stackflow/core`) resolve + * - a minimal inline renderer plugin (public `render` API) renders activities, + * so specs do not need `@stackflow/plugin-renderer-basic` (which would + * create a workspace dependency cycle) + * + * Feel free to remove this file once real specs cover the same ground. + */ +import { defineConfig } from "@stackflow/config"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import type { StackflowReactPlugin } from "./index"; +import { stackflow } from "./index"; + +declare module "@stackflow/config" { + interface Register { + SmokeActivity: {}; + } +} + +const testRendererPlugin: StackflowReactPlugin = () => ({ + key: "test-renderer", + render({ stack }) { + return ( + <> + {stack.render().activities.map((activity) => ( + + {activity.render()} + + ))} + + ); + }, +}); + +describe("test harness", () => { + it("renders an activity through a minimal inline renderer plugin", () => { + // given + function SmokeActivity() { + return
smoke
; + } + + const config = defineConfig({ + activities: [{ name: "SmokeActivity" }], + transitionDuration: 0, + initialActivity: () => "SmokeActivity", + }); + + const { Stack } = stackflow({ + config, + components: { SmokeActivity }, + plugins: [testRendererPlugin], + }); + + // when + render(); + + // then + expect(screen.getByText("smoke")).toBeTruthy(); + }); +}); diff --git a/integrations/react/tsconfig.json b/integrations/react/tsconfig.json index 4ed7abc2b..1a5f1d213 100644 --- a/integrations/react/tsconfig.json +++ b/integrations/react/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "./src", "outDir": "./dist" }, - "exclude": ["./dist"] + "exclude": ["./dist", "**/*.spec.ts", "**/*.spec.tsx"] } diff --git a/integrations/react/tsconfig.test.json b/integrations/react/tsconfig.test.json new file mode 100644 index 000000000..519b0a584 --- /dev/null +++ b/integrations/react/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "exclude": ["./dist"] +} diff --git a/yarn.lock b/yarn.lock index a9e7e77b7..55fd2eb6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6027,10 +6027,19 @@ __metadata: "@stackflow/config": "npm:^2.0.0" "@stackflow/core": "npm:^2.0.0" "@stackflow/esbuild-config": "npm:^1.0.3" + "@swc/core": "npm:^1.6.6" + "@swc/jest": "npm:^0.2.36" + "@testing-library/dom": "npm:^10.4.0" + "@testing-library/react": "npm:^16.3.2" + "@types/jest": "npm:^29.5.12" "@types/react": "npm:^18.3.3" + "@types/react-dom": "npm:^18.3.0" esbuild: "npm:^0.23.0" esbuild-plugin-file-path-extensions: "npm:^2.1.2" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" react: "npm:^18.3.1" + react-dom: "npm:^18.3.1" react-fast-compare: "npm:^3.2.2" rimraf: "npm:^3.0.2" typescript: "npm:^5.5.3" From 785f5839e019b19d936e28e482398c674c129021 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 4 Jun 2026 15:59:11 +0900 Subject: [PATCH 2/6] docs: add FEP-2357 spec and reviewer-approved test plan (rev 3) - FEP-2357-SPEC.md: Linear issue + locked interface design, plus spec-owner decisions (reject semantics, original-reason propagation, retry after failure; loader dedupe / chunk duplicate firing / atomicity left unspecified) - FEP-2357-TEST-PLAN.md: 32 given-when-then test items (A-G), approved by test reviewer after two review rounds Co-Authored-By: Claude Opus 4.8 (1M context) --- FEP-2357-SPEC.md | 123 +++++++++++++++ FEP-2357-TEST-PLAN.md | 356 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 FEP-2357-SPEC.md create mode 100644 FEP-2357-TEST-PLAN.md diff --git a/FEP-2357-SPEC.md b/FEP-2357-SPEC.md new file mode 100644 index 000000000..ec735a84a --- /dev/null +++ b/FEP-2357-SPEC.md @@ -0,0 +1,123 @@ +# FEP-2357: React 맥락 바깥에서 Activity Component / 데이터 preload 지원 + +> 이 문서는 Linear FEP-2357 이슈 본문 + 확정된 인터페이스 기획안(이슈 코멘트)을 워킹트리로 옮긴 것이다. +> 테스트 기획/구현/리뷰의 단일 기준(source of truth)이다. + +## 배경 + +Stackflow React integration에서 activity 진입 전 chunk/데이터를 미리 로드하는 로직은 현재 +`usePrepare` 훅으로 제공된다(`integrations/react/src/usePrepare.ts`). 그러나 이 훅은 React +Context에 의존하기 때문에(`useConfig`, `useDataLoader`, `useActivityComponentMap`) +**React 렌더링 트리 내부에서만** 호출할 수 있다. + +이로 인해 **React Render 이전 시점**(앱 부트스트랩 단계, 라우팅 진입 직전 등 React 바깥 +맥락)에서는 activity component chunk와 관련 데이터를 preload 할 수 없다. + +## 요구사항 + +- React Context에 의존하지 않고, React 렌더링 이전에 호출 가능한 형태로 preload 기능을 제공한다. +- preload 대상: activity component chunk(lazy loaded component), 그리고 관련 데이터(activity + config의 data loader). +- TypeScript 타입 안전성은 그대로 유지한다(잘못된 activity 이름/파라미터는 컴파일 타임에 + 걸러져야 함). + +## 확정된 인터페이스 (변경 불가) + +### 1. 어디에 — `stackflow()` 출력에 `prepare` 추가 + +```ts +const { Stack, actions, stepActions, prepare } = stackflow({ + config, + components, + plugins, +}); +``` + +- `actions` / `stepActions`와 동일한 "렌더 밖 호출용" 선례를 따름. +- `config`·`components`를 다시 넘길 필요 없이 **같은 stackflow 인스턴스 1개**에서 나오므로 + 단일 출처 보장. +- `actions`와 달리 **core store를 건드리지 않으므로**, `stackflow()`가 호출되는 모듈 평가 + 시점부터 즉시 동작 가능 — `` 마운트 이전에도. + +### 2. 어떤 형태 — 현행 단일 시그니처 유지 + +```ts +type Prepare = ( + activityName: K, + activityParams?: InferActivityParams, +) => Promise; +``` + +- `params` 생략 → activity component chunk만 preload. +- `params` 전달 → chunk + 해당 activity data loader까지 발사. +- 반환 `Promise`는 모든 preload 작업 완료 시 resolve. 로더 결과를 저장하진 않으며(캐시 + 워밍/네트워크 발사가 목적), 실제 loaderData 주입은 기존 `loaderPlugin`이 담당. + +### 3. 이름 — `prepare` 유지 + +- 기존 `usePrepare`가 돌려주던 `Prepare` 타입/이름 그대로 재사용. +- `usePrepare`는 동일 로직을 감싸는 얇은 래퍼로 전환 → React 트리 안의 기존 호출자 무중단, + 렌더 밖/안이 단일 구현 공유. + +### 타입 안전성 + +`RegisteredActivityName` · `InferActivityParams` 제네릭이 그대로 흐르므로 잘못된 activity +이름·파라미터는 **컴파일 타임에 차단**된다. + +### 사용 시나리오 + +```ts +// (A) React 밖 — 앱 부트스트랩 / 라우팅 진입 직전 +prepare("Article", { articleId: "123" }); // chunk + data 미리 발사 +prepare("Article"); // chunk만 + +// (B) React 안 — 기존 코드 그대로 동작 +const prepare = usePrepare(); +``` + +## 현행 동작 참고 (usePrepare 기준) + +현행 `usePrepare`가 반환하는 `prepare`의 관찰 가능한 동작(새 `prepare`도 동일해야 함): + +- 등록되지 않은 activity 이름 → `Activity ${name} is not registered.` 에러를 throw. +- `activityParams`가 주어지고 activity config에 `loader`가 있으면 loader를 호출. +- 컴포넌트가 `lazy()`로 만들어진 경우(`_load` 보유) chunk 로드를 발사. +- 컴포넌트가 `structuredActivityComponent()`이고 `content`가 dynamic import 함수인 경우 + content chunk preload를 발사. +- 반환 Promise는 위에서 발사된 모든 작업이 완료되면 resolve. + +## 추가 확정 사항 (2026-06-04, 스펙 오너 결정) + +테스트 기획 과정에서 제기된 미결정 사항(Open Questions)에 대한 스펙 오너의 확정. + +### 계약으로 확정 (테스트로 고정한다) + +- **에러 전달 방식**: 미등록 activity 등 모든 실패는 동기 throw가 아니라 **반환 Promise의 + reject**로 전달된다. (OQ-3) +- **실패 전파**: loader/chunk 로드 실패 시 반환 Promise는 **원본 reason으로 reject**된다. + 항상-resolve+로깅이 아니다. (OQ-4) +- **실패 후 재시도**: chunk 로드 실패 후 같은 activity를 다시 `prepare`하면 chunk 로드를 + **재시도한다**. 실패가 캐시를 영구 오염시키지 않는다. (OQ-6) + +> **문서화 안내**: 실패가 reject로 전파되므로, 사용 시나리오 (A)처럼 fire-and-forget으로 +> 호출하는 경우 unhandled rejection을 피하기 위해 `.catch` 사용을 권장한다는 안내를 공식 +> 문서에 포함해야 한다. (예: `prepare("Article", { ... }).catch(() => {})`) + +### 명시적 미규정 (Unspecified behavior — 테스트로 고정하지 않는다) + +아래는 구현 상세로 남긴다. 테스트는 이 동작을 어느 방향으로도 단언해서는 안 된다. + +- **중복 `prepare` 시 data loader 디듀프 여부** — 현재 구현은 디듀프하지 않지만 계약이 + 아니다. (OQ-1) +- **chunk import 중복 발사 여부** — `lazy()` 구현의 캐시에 맡긴다. `prepare` 레벨 계약이 + 아니다. (OQ-2) +- **부분 발사 원자성/취소** — loader 동기 throw 시 나머지 preload 발사 여부는 보장하지 + 않는다. 취소(cancellation)도 제공하지 않는다. (OQ-5) + +## 관련 소스 + +- `integrations/react/src/usePrepare.ts` — 현행 훅 (이관 대상 로직) +- `integrations/react/src/stackflow.tsx` — `stackflow()` 팩토리, `loadData` 클로저 +- `integrations/react/src/lazy.tsx` — `lazy()` / `_load` +- `integrations/react/src/StructuredActivityComponentType.tsx` — structured component / preload +- `integrations/react/src/index.ts` — 패키지 public entry diff --git a/FEP-2357-TEST-PLAN.md b/FEP-2357-TEST-PLAN.md new file mode 100644 index 000000000..d617e7f0e --- /dev/null +++ b/FEP-2357-TEST-PLAN.md @@ -0,0 +1,356 @@ +# FEP-2357 테스트 계획: `prepare` / `usePrepare` + +> 기준 문서: `FEP-2357-SPEC.md` (single source of truth — "추가 확정 사항" 섹션 포함). +> 모든 테스트는 `@stackflow/react`의 Public API(`integrations/react/src/index.ts` export 기준)와 +> `@stackflow/config`의 Public API만 사용한다. 내부 모듈(`SyncInspectablePromise`, +> `loaderPlugin`, `_load`, 내부 Context 등) 직접 접근 금지. +> 스펙이 **명시적 미규정(Unspecified)** 으로 남긴 동작(loader 디듀프, chunk 중복 발사, +> 부분 발사 원자성/취소)은 테스트가 **어느 방향으로도 단언하지 않는다** — §5 참고. + +## 0. 실행 환경 + +- 실행: `yarn workspace @stackflow/react test` (Jest + jsdom + @swc/jest) +- 타입 검증: `yarn workspace @stackflow/react typecheck` (`tsconfig.test.json`, spec 포함) +- 스펙 파일 위치: `integrations/react/src/*.spec.tsx` +- 스타일: given-when-then 주석 패턴 (`extensions/plugin-blocker/src/blockerPlugin.spec.tsx` 참고), + 렌더가 필요한 테스트는 인라인 렌더러 플러그인 사용 (`harness.smoke.spec.tsx` 패턴 — + `@stackflow/plugin-renderer-basic`은 워크스페이스 순환 의존이라 사용 불가) +- **import 경계**: 패키지 내부 spec은 모두 `./index`(public entry)에서 import한다. + `"@stackflow/react"` 패키지명 import는 `dist`(빌드 산출물)를 가리키므로 **금지** — + 작업 중인 `src` 변경 대신 stale artifact를 검증하게 된다. package export 검증은 + 별도 build/publish 테스트의 책임이다. (`harness.smoke.spec.tsx`와 동일한 경계) + +## 1. 파일 구성 + +| 파일 | 내용 | +|---|---| +| `integrations/react/src/prepare.spec.tsx` | A·B·C·E·F (런타임 규약) | +| `integrations/react/src/usePrepare.spec.tsx` | D (래퍼 동등성) | +| `integrations/react/src/prepare.types.spec.tsx` | G (타입 안전성) + 최소 런타임 항목(A1 배치) | + +주의: + +- 타입 테스트 파일도 반드시 `*.spec.tsx`로 명명한다 — 빌드 tsconfig/esbuild가 `*.spec.*`을 + 제외하므로 dist 오염이 없고, `tsconfig.test.json`은 spec을 포함하므로 typecheck가 검증한다. +- `@swc/jest`는 타입을 검사하지 않으므로 `@ts-expect-error` 대상 코드가 **런타임에 실행되면 + 안 된다** → 타입 단언은 절대 호출되지 않는 함수 본문 안에 배치한다. +- Jest는 spec 파일에 최소 1개 테스트를 요구하므로 G 파일에 런타임 항목(A1)을 함께 둔다. +- `declare module "@stackflow/config"`의 `Register` 증강은 패키지 전역으로 병합된다. + spec 파일 간 이름 충돌 방지를 위해 activity 이름에 `Prepare` 접두사를 사용한다 + (예: `PrepareLazyActivity`, `PrepareLoaderActivity` — `SmokeActivity`는 이미 사용 중). + +## 2. 공통 픽스처 / 유틸리티 + +```ts +// 제어 가능한 비동기 작업 +function createDeferred(): { promise: Promise; resolve: (v: T) => void; reject: (e: unknown) => void }; + +// pending 검사: then-플래그 + 마이크로태스크 flush. Promise 내부 구조에 의존하지 않는다. +async function isSettled(p: Promise): Promise; +// 구현 스케치: let settled = false; p.then(() => { settled = true; }, () => { settled = true; }); +// await flushMicrotasks(); return settled; +``` + +- **인라인 렌더러 플러그인**: `harness.smoke.spec.tsx`의 `testRendererPlugin` 패턴. + E4(마운트 중 chunk pending)에서는 activity 렌더를 ``으로 감싼 + 변형이 필요하다 (lazy 컴포넌트가 pending chunk에서 suspend하므로). +- **spy 플러그인**: `onInit({ actions })`에서 `getStack` 캡처 + `onChanged`/`onBeforePush`를 + `jest.fn`으로 기록 (blockerPlugin.spec.tsx 패턴). +- **lazy 픽스처**: `lazy(jest.fn(() => deferred.promise))` — 사용자가 공급하는 import 함수의 + 호출 여부/인자는 공개 경계에서의 관찰이다 (횟수 단언 허용 범위는 §4 자체 점검 참고). + - **디듀프-불가지(agnostic) 픽스처**: 중복 호출이 등장하는 테스트(E1)의 import 함수는 + **호출될 때마다 동일한 deferred.promise를 반환**해야 한다 — 구현이 디듀프하든 안 하든 + 테스트 결과가 같도록. (chunk 중복 발사 여부는 스펙 미규정 — §5) +- **loader 픽스처**: `defineConfig`의 activity에 `loader: jest.fn(...)` — 마찬가지로 사용자 + 공급 함수. 인자 형태는 공개 타입 `ActivityLoaderArgs`(`{ params, config }`)로 단언한다. + F1에서는 동기 값을 반환하는 loader(`() => ({ message: "loaded" })`)를 사용해 렌더를 + 결정적으로 만든다. +- **미등록 activity 런타임 호출**: 타입이 컴파일 타임에 차단하므로(G1) 런타임 테스트(A8, D2)는 + `as any` 캐스트로 우회해 호출한다. + +--- + +## 3. 테스트 항목 + +표기: 각 항목 끝의 `[근거]`는 스펙 문구(§는 스펙의 절)다. +각 항목은 단일 규약을 검증하며, Then은 그 규약의 직접 관찰만 단언한다. + +### A. `prepare` 기본 규약 — `stackflow()` 출력, 렌더 없이 호출 + +#### A1. `stackflow()` 출력에 `prepare` 함수가 포함된다 +- **Given**: `defineConfig` + components로 `stackflow()`를 호출한다. +- **When**: 반환 객체를 확인한다. +- **Then**: `typeof prepare === "function"`이다. +- [근거: 스펙 §1 "stackflow() 출력에 prepare 추가"] + +#### A2. params 생략 시 component chunk 로드만 발사하고 data loader는 호출하지 않는다 +- **Given**: `loader: jest.fn()`이 설정된 activity와 `lazy(jest.fn(() => Promise.resolve({ default: Comp })))` 컴포넌트. +- **When**: `await prepare("PrepareLazyActivity")` — params 없이 호출한다. +- **Then**: import 함수는 호출되고, loader는 호출되지 않는다. +- [근거: 스펙 §2 "params 생략 → chunk만 preload"] + +#### A3. params 전달 시 chunk 로드와 data loader를 모두 발사한다 +- **Given**: `loader: jest.fn()` + lazy 컴포넌트(import `jest.fn`)인 activity. +- **When**: `await prepare("PrepareLazyActivity", { id: "1" })`. +- **Then**: loader가 `expect.objectContaining({ params: { id: "1" }, config: expect.anything() })` 인자로 호출되고, import 함수도 호출된다. +- [근거: 스펙 §2 "params 전달 → chunk + data loader까지 발사", `ActivityLoaderArgs` 공개 타입] + +#### A4. loader가 없는 activity에 params를 전달해도 에러 없이 resolve된다 +- **Given**: loader 없는 config + lazy 컴포넌트. +- **When**: `prepare("A", { id: "1" })`. +- **Then**: 반환 Promise가 에러 없이 resolve된다. (chunk 발사 검증은 A2의 규약) +- [근거: 스펙 "현행 동작" — loader는 "있으면" 호출] + +#### A5. lazy도 structured도 아닌 일반 컴포넌트는 아무 작업도 발사하지 않고 resolve된다 +- **Given**: 일반 함수 컴포넌트, loader 없는 activity. +- **When**: `prepare("A")`. +- **Then**: 반환 Promise가 에러 없이 resolve된다. +- [근거: 스펙 "현행 동작" — 발사 조건(lazy/structured/loader)에 해당하지 않으면 발사할 작업이 없음] + +#### A6. `structuredActivityComponent`의 dynamic content는 content import를 발사한다 +- **Given**: `structuredActivityComponent({ content: jest.fn(() => Promise.resolve({ default: content(Comp) })) })`. +- **When**: `await prepare("A")`. +- **Then**: content import 함수가 호출된다. +- [근거: 스펙 "현행 동작" — structured + dynamic content → content chunk preload 발사] + +#### A7. `structuredActivityComponent`의 정적 content는 추가 로드 없이 resolve된다 +- **Given**: `structuredActivityComponent({ content: content(Comp) })` — content가 함수가 아닌 정적 값. +- **When**: `prepare("A")`. +- **Then**: 반환 Promise가 에러 없이 resolve된다 (동적 import 함수가 없으므로 호출 검증 대상도 없음). +- [근거: 스펙 "현행 동작" — "content가 dynamic import 함수인 경우"에만 발사] + +#### A8. 미등록 activity 이름으로 호출하면 `Activity ${name} is not registered.` 에러로 reject된다 +- **Given**: `"Known"` activity만 등록된 stackflow 인스턴스. +- **When**: `const p = prepare("Unknown" as any)`. +- **Then**: `p`가 `Activity Unknown is not registered.` 메시지의 Error로 reject된다. + (동기 throw라면 호출 시점에 테스트가 실패하므로, 이 단언이 "throw가 아닌 reject" 계약을 함께 고정한다) +- [근거: 스펙 "현행 동작" — 미등록 이름 에러, 스펙 "추가 확정 사항 — 에러 전달 방식": 모든 실패는 Promise reject로 전달] + +#### A9. 빈 객체 params도 "params 전달"로 취급되어 loader가 호출된다 +- **Given**: 파라미터가 없는(`{}` 타입) activity + `loader: jest.fn()`. +- **When**: `await prepare("A", {})`. +- **Then**: loader가 호출된다 (`prepare("A")`처럼 생략한 경우와 달리). +- [근거: 스펙 "현행 동작" — "activityParams가 주어지고 loader가 있으면 호출". 파라미터 없는 activity의 데이터 preload 경로를 고정] + +### B. 반환 Promise 의미 — 모든 작업 완료 시에만 resolve + +#### B1. chunk 로드가 완료되기 전에는 resolve되지 않고, 완료되면 resolve된다 +- **Given**: deferred로 제어되는 lazy import 함수. +- **When**: `const p = prepare("A")` 후 마이크로태스크를 flush한다. +- **Then**: `p`는 아직 settle되지 않았다. deferred를 resolve하면 `p`가 resolve된다. +- [근거: 스펙 §2 "반환 Promise는 모든 preload 작업 완료 시 resolve"] + +#### B2. loader만 완료되고 chunk가 미완료인 동안에는 resolve되지 않는다 (중간 상태 미노출) +- **Given**: deferred 2개 — loader는 `() => loaderDeferred.promise`, lazy import는 `() => chunkDeferred.promise`. +- **When**: `const p = prepare("A", params)`; `loaderDeferred.resolve(...)`; flush. +- **Then**: `p`는 여전히 pending. `chunkDeferred.resolve(...)` 후 resolve된다. +- [근거: 동일 — "모든" 작업 완료] + +#### B3. chunk만 완료되고 loader가 미완료인 동안에는 resolve되지 않는다 (B2의 대칭) +- **Given**: B2와 동일한 픽스처. +- **When**: `const p = prepare("A", params)`; `chunkDeferred.resolve(...)`; flush. +- **Then**: `p`는 여전히 pending. `loaderDeferred.resolve(...)` 후 resolve된다. +- [근거: 동일] + +### C. React 밖 / 렌더 전 호출 가능성 + +#### C1. `` 렌더 없이(React 트리 부재) `prepare`가 완전한 동작을 한다 +- **Given**: `stackflow()` 호출 직후, 어떤 컴포넌트도 렌더하지 않은 상태 (loader + lazy activity). +- **When**: `await prepare("A", params)`. +- **Then**: loader와 import 함수가 모두 호출된다. +- 참고: A·B 절 전체가 렌더 없이 실행되어 사실상 이 전제를 상시 검증하지만, 이 항목은 "렌더 + 이전·React 바깥 호출 가능"을 명시적 규약으로 고정하는 대표 테스트다. +- [근거: 스펙 §1 "`` 마운트 이전에도 즉시 동작", 요구사항 "React 렌더링 이전에 호출 가능"] + +#### C2. 렌더 전 `prepare` 호출이 이후 `` 마운트를 방해하지 않는다 +- **Given**: lazy activity `"A"`에 대해 `await prepare("A")` 완료. `initialActivity`는 일반 컴포넌트 `"Main"`. +- **When**: `render()` (인라인 렌더러 플러그인). +- **Then**: `"Main"`이 정상 렌더된다. +- [근거: 스펙 사용 시나리오 (A) — 부트스트랩에서 prepare 후 정상 렌더] + +### D. `usePrepare` 래퍼 동등성 + +#### D1. `usePrepare`가 반환한 함수도 chunk + data를 동일하게 발사한다 +- **Given**: `` 렌더(초기 activity 내부에서 `usePrepare()` 반환값을 외부 변수로 캡처). + 별도의 lazy + loader activity `"B"`. +- **When**: 캡처한 함수로 `await capturedPrepare("B", { id: "1" })`. +- **Then**: loader가 `objectContaining({ params: { id: "1" } })` 인자로 호출되고, import 함수가 호출된다 — A3과 동일한 관찰 결과. +- [근거: 스펙 §3 "usePrepare는 동일 로직을 감싸는 얇은 래퍼", "현행 동작… 새 prepare도 동일해야 함"] + +#### D2. `usePrepare`가 반환한 함수도 미등록 activity에 동일 에러로 reject된다 +- **Given**: D1과 동일하게 캡처한 함수. +- **When**: `capturedPrepare("Unknown" as any)`. +- **Then**: `Activity Unknown is not registered.` 에러로 reject된다 — A8과 동일. +- [근거: 동일, 스펙 "추가 확정 사항 — 에러 전달 방식"] + +### E. 동시성 · 경쟁 상태 · 실패 + +#### E1. 동일 activity에 대한 동시 중복 `prepare` — 두 Promise 모두 작업 완료 후 각각 resolve된다 +- **Given**: deferred chunk를 가진 lazy activity. import 함수는 호출마다 **동일한** + deferred.promise를 반환한다(디듀프-불가지 픽스처 — §2). +- **When**: `const p1 = prepare("A"); const p2 = prepare("A");` flush → 둘 다 pending 확인 → deferred resolve. +- **Then**: `p1`, `p2` 모두 resolve된다. (import 함수/loader의 호출 횟수는 단언하지 않는다 — 스펙 미규정, §5) +- [근거: 스펙 §2의 Promise 의미를 호출 단위로 적용 — 각 호출의 Promise는 독립적으로 완료를 보고한다] + +#### E2. 서로 다른 activity의 동시 `prepare`는 서로 간섭하지 않는다 +- **Given**: `"A"`(chunkA deferred), `"B"`(chunkB deferred) — 둘 다 lazy. +- **When**: `const pA = prepare("A"); const pB = prepare("B");` → `chunkB`만 resolve → flush. +- **Then**: `pB`는 resolve되고 `pA`는 여전히 pending이다. `chunkA` resolve 후 `pA`도 resolve된다. +- [근거: 호출별 독립성 — 각 호출의 Promise는 "자신이 발사한" 작업 완료에만 묶인다(스펙 §2)] + +#### E3. `prepare` 진행 중 같은 activity로 `push`가 발생해도 push는 정상 완료된다 +- **Given**: `` 렌더(initial: 일반 `"Main"`), deferred chunk의 lazy `"A"`, spy 플러그인(getStack). `prepare("A")` 발사(미완료). +- **When**: `actions.push("A", {})` 호출 → 이후 deferred resolve → settle 대기. +- **Then**: 스택이 기존 + 1개가 되고 top이 `"A"`(`enteredBy.name === "Pushed"`)다. +- [근거: 스펙 §1 "core store를 건드리지 않음" — prepare가 내비게이션과 경쟁해도 push 시맨틱 불변] + +#### E4. `prepare` 진행 중 `` 마운트(부트스트랩 시나리오)도 정상 동작한다 +- **Given**: deferred chunk의 lazy `"A"`(loader 없음), `initialActivity: () => "A"`. Suspense 래핑 인라인 렌더러. `prepare("A")` 발사 직후(미완료). +- **When**: `render()` → deferred resolve → settle 대기. +- **Then**: `"A"`의 콘텐츠가 렌더된다. +- [근거: 스펙 사용 시나리오 (A) — "앱 부트스트랩 / 라우팅 진입 직전" 호출과 렌더의 중첩] + +#### E5. loader가 동기 throw하면 반환 Promise는 해당 에러로 reject된다 +- **Given**: `loader: () => { throw err; }`인 activity (+ lazy 컴포넌트). +- **When**: `const p = prepare("A", params)`. +- **Then**: `p`가 `err`로 reject된다 (동기 throw로 전파되지 않는다). + chunk 발사 여부는 단언하지 않는다 — 부분 발사 원자성은 스펙 미규정(§5). +- [근거: 스펙 "추가 확정 사항 — 실패 전파": 원본 reason으로 reject / "에러 전달 방식": throw가 아닌 reject] + +#### E6. loader가 비동기 reject하면 반환 Promise는 해당 reason으로 reject된다 +- **Given**: `loader: () => Promise.reject(err)`인 activity. +- **When**: `const p = prepare("A", params)`. +- **Then**: `p`가 `err`로 reject된다. +- [근거: 스펙 "추가 확정 사항 — 실패 전파"] + +#### E7. chunk 로드가 reject하면 반환 Promise는 해당 reason으로 reject된다 +- **Given**: `lazy(() => Promise.reject(err))`인 activity. +- **When**: `const p = prepare("A")`. +- **Then**: `p`가 `err`로 reject된다. +- [근거: 스펙 "추가 확정 사항 — 실패 전파"] + +#### E8. chunk 로드 실패 후 같은 activity를 다시 `prepare`하면 로드를 재시도한다 +- **Given**: 첫 호출은 reject, 두 번째 호출은 resolve하는 lazy import + (`jest.fn().mockRejectedValueOnce(err).mockResolvedValueOnce({ default: Comp })`). +- **When**: `prepare("A")`의 reject를 확인한 뒤 → `const p2 = prepare("A")`. +- **Then**: import 함수가 다시 호출되고(총 2회) `p2`는 resolve된다. + (재호출이 곧 "재시도" 계약의 직접 관찰이다 — 캐시된 실패가 반환되면 p2가 reject되어 구분된다) +- [근거: 스펙 "추가 확정 사항 — 실패 후 재시도": 실패가 캐시를 영구 오염시키지 않는다] + +#### E9. `prepare` 실패가 이후 내비게이션과 다른 `prepare`를 오염시키지 않는다 (오류 격리 invariant) +- **Given**: loader가 reject하는 `"A"`, 정상 lazy + loader의 `"B"`, `` 렌더 + spy 플러그인. +- **When**: `prepare("A", params)`의 reject를 확인한 뒤 → `await prepare("B", params)` → `actions.push("B", params)`. +- **Then**: `prepare("B")`는 resolve되고, push 후 스택 top이 `"B"`다. +- [근거: 단일 출처 인스턴스(스펙 §1)에서 호출 간 독립성 — 실패가 인스턴스 상태를 손상시키지 않아야 함] + +#### E10. `prepare`는 스택 상태를 변경하지 않으며 내비게이션 이벤트를 발생시키지 않는다 +- **Given**: `` 렌더, spy 플러그인(`getStack` + `onChanged`/`onBeforePush`/`onPushed`를 `jest.fn`으로 기록), loader + lazy의 `"A"`. +- **When**: 스택 스냅샷 채취 → `await prepare("A", params)` → 재채취. +- **Then**: `getStack().activities`가 prepare 전후 동등하고, 기록된 플러그인 훅(`onChanged`/`onBeforePush`/`onPushed`)이 prepare로 인해 추가 호출되지 않았다. + (두 단언 모두 "core store 미접촉"이라는 단일 규약의 관찰 지점이다) +- [근거: 스펙 §1 "actions와 달리 core store를 건드리지 않으므로"] + +### F. `loaderPlugin`과의 책임 분리 + +> 주의: 이 절은 호출 횟수를 단언하지 않는다. loader 디듀프·chunk 중복 발사 여부는 +> 스펙 미규정(§5)이며, 여기서는 "prepare가 기존 내비게이션 경로(loaderData 주입·lazy 렌더)를 +> 방해하지 않는다"는 책임 분리만 검증한다. + +#### F1. `prepare` 후 `push`해도 loaderData 주입은 loaderPlugin 경로로 정상 동작한다 +- **Given**: 동기 데이터를 반환하는 `loader: () => ({ message: "loaded" })`의 `"A"`, + `"A"` 컴포넌트는 `useLoaderData()` 값을 렌더. `` 렌더(initial: `"Main"`). +- **When**: `await prepare("A", params)` → `actions.push("A", params)` → settle 대기. +- **Then**: `"A"`가 loader 데이터(`"loaded"`)와 함께 렌더된다 — prepare가 loaderData 주입 + 경로를 가로채거나 망가뜨리지 않는다. +- [근거: 스펙 §2 "로더 결과를 저장하진 않으며… 실제 loaderData 주입은 기존 loaderPlugin이 담당"] + +#### F2. `prepare` 완료 후 `push`하면 lazy activity가 정상 렌더된다 +- **Given**: `lazy(() => Promise.resolve({ default: Comp }))`의 `"A"`, `` 렌더(initial: `"Main"`). +- **When**: `await prepare("A")` → `actions.push("A", {})` → settle 대기. +- **Then**: `"A"`의 콘텐츠가 렌더된다 — 워밍된 chunk가 이후 내비게이션 렌더를 방해하지 않는다. + (import 호출 횟수는 단언하지 않는다 — 스펙 미규정, §5) +- [근거: 스펙 §2 "캐시 워밍/네트워크 발사가 목적" — prepare→push 시퀀스의 무간섭] + +### G. 타입 안전성 — `yarn typecheck`로 검증 (`prepare.types.spec.tsx`) + +> 모든 타입 단언은 **절대 호출되지 않는 함수 본문** 안에 배치한다(런타임 부작용 방지). +> `@ts-expect-error`는 "다음 줄에 컴파일 에러가 있어야 통과" 시맨틱이므로, 규약이 깨지면 +> typecheck가 실패한다. import는 `./index`(public entry)에서만 한다 — §0 import 경계 참고. + +#### G1. 미등록 activity 이름은 컴파일 에러다 +- **Given**: `Register`에 증강되지 않은 이름. +- **When**: `// @ts-expect-error` + `prepare("NotRegistered")`. +- **Then**: typecheck 통과(= 해당 줄이 실제로 에러). +- [근거: 스펙 "타입 안전성 — 잘못된 activity 이름·파라미터는 컴파일 타임에 차단"] + +#### G2. 잘못된 params 타입은 컴파일 에러다 +- **Given**: `Register`에 `{ id: string }`으로 증강된 activity. +- **When**: `// @ts-expect-error` + `prepare("A", { id: 123 })`, `// @ts-expect-error` + `prepare("A", { wrong: "x" })`. +- **Then**: typecheck 통과. +- [근거: 동일 — `InferActivityParams` 흐름] + +#### G3. params는 생략 가능하고 반환 타입은 `Promise`다 +- **Given**: 등록된 activity. +- **When**: `const r1: Promise = prepare("A");` / `const r2: Promise = prepare("A", validParams);`. +- **Then**: 에러 없이 typecheck 통과. +- [근거: 스펙 §2 `Prepare` 시그니처 — 옵셔널 params, `Promise` 반환] + +#### G4. `stackflow()` 출력 `prepare`와 `usePrepare` 반환값은 모두 `Prepare` 타입과 상호 할당 가능하다 +- **Given**: `import { stackflow, usePrepare, type Prepare } from "./index"`. +- **When**: `const _a: Prepare = output.prepare;` / `declare const up: ReturnType; const _b: Prepare = up;` 및 역방향 할당. +- **Then**: 에러 없이 typecheck 통과 — 두 진입점이 동일한 공개 시그니처를 공유한다. +- [근거: 스펙 §3 "기존 usePrepare가 돌려주던 `Prepare` 타입/이름 그대로 재사용"] + +--- + +## 4. 자체 점검 — 구현 상세가 아닌 공개 규약인가 + +| 점검 | 결과 | +|---|---| +| 사용 API | `stackflow`, `lazy`, `structuredActivityComponent`/`content`, `usePrepare`, `useLoaderData`, `Prepare`, `StackflowReactPlugin`(스파이/렌더러), `Actions`(push), `defineConfig`/`ActivityLoaderArgs`(@stackflow/config) — 전부 public export | +| import 경계 | 패키지 내부 spec은 `./index`(public entry)만 사용. `"@stackflow/react"` 패키지명 import 금지(dist를 가리킴) | +| 비사용(금지) | `SyncInspectablePromise`, `preloadableLazyComponent`, `loaderPlugin` 직접 import, `_load` 직접 접근, 내부 Context, `getContentComponent` | +| `jest.fn` import/loader 호출 단언 | import 함수·loader는 **사용자가 공급하는 값**이므로 호출 여부·인자는 공개 경계의 관찰이다. 호출 **횟수** 단언은 계약이 횟수를 직접 함의하는 곳에만 둔다 — chunk-only의 loader 미호출(A2), 실패 후 재시도의 재호출(E8). **디듀프/중복 발사 관련 횟수는 어디에서도 단언하지 않는다**(스펙 미규정) | +| 미규정 동작 보호 | loader 디듀프(OQ-1)·chunk 중복 발사(OQ-2)·부분 발사 원자성/취소(OQ-5)는 어느 방향으로도 단언하지 않음. E1은 디듀프-불가지 픽스처 사용, E5는 chunk 발사 여부 미단언, F절은 횟수 대신 경로 정상 동작만 검증 | +| Promise pending 검사 | then-콜백 플래그 + 마이크로태스크 flush — `Promise.all` 등 내부 구성에 의존하지 않음 | +| 스택 상태 단언 | spy 플러그인의 공개 `actions.getStack()` 경유 (기존 blockerPlugin spec과 동일 패턴) | +| 렌더 단언 | Testing Library `screen` — DOM 관찰 | +| 단언 범위 | 한 항목 = 단일 규약. Then은 해당 규약의 직접 관찰만 단언하며, 인접 규약(resolve 의미·렌더 성공 등)은 그 규약을 담당하는 항목에 위임 | + +## 5. 스펙 확정 사항과 테스트 매핑 + +스펙 오너가 `FEP-2357-SPEC.md` "추가 확정 사항"(2026-06-04)으로 확정한 내용과 +이 계획의 대응이다. + +### 계약으로 확정 → 테스트로 고정 + +| 확정 계약 | 검증 항목 | +|---|---| +| 에러 전달 방식 — 모든 실패는 동기 throw가 아닌 **Promise reject** (구 OQ-3) | A8, D2, E5(throw 미전파) | +| 실패 전파 — loader/chunk 실패 시 **원본 reason으로 reject** (구 OQ-4) | E5, E6, E7 | +| 실패 후 재시도 — chunk 실패 후 재-`prepare`는 **로드 재시도** (구 OQ-6) | E8 | + +### 명시적 미규정 → 단언 금지 가드레일 + +| 미규정 동작 | 계획의 대응 | +|---|---| +| 중복 `prepare` 시 data loader 디듀프 여부 (구 OQ-1) | 어떤 항목도 중복 호출 시 loader 횟수를 단언하지 않음. E1은 Promise 의미만 검증 | +| chunk import 중복 발사 여부 — `lazy()` 구현의 캐시에 맡김 (구 OQ-2) | E1은 디듀프-불가지 픽스처(호출마다 동일 promise 반환) 사용. F2는 횟수 대신 렌더 무간섭만 검증 | +| 부분 발사 원자성/취소 (구 OQ-5) | E5는 reject만 단언하고 chunk 발사 여부는 단언하지 않음. 취소 관련 테스트 없음 | + +> 이전 rev에서 호출 횟수로 디듀프/워밍을 고정하던 항목(중복 prepare 시 loader 재호출, +> 중복 prepare 시 chunk 1회, prepare→push loader 2회/import 1회)은 미규정 침해이므로 +> **제거 또는 재구성**했다(F1·F2는 경로 정상 동작 검증으로 전환). + +## 6. 항목 요약 + +| 절 | 항목 수 | 내용 | +|---|---|---| +| A | 9 | 기본 규약 (chunk-only / chunk+data / structured / 미등록 / 경계) | +| B | 3 | 반환 Promise 의미 (전체 완료 시 resolve, 중간 상태 미노출) | +| C | 2 | React 밖 / 렌더 전 호출 | +| D | 2 | usePrepare 래퍼 동등성 | +| E | 10 | 동시성 · 재진입 · 경쟁 상태 · 실패 · 재시도 · invariant | +| F | 2 | loaderPlugin 책임 분리 (주입 경로·렌더 무간섭 — 횟수 단언 없음) | +| G | 4 | 타입 안전성 (typecheck 기반) | +| **계** | **32** | 스펙 "추가 확정 사항" 반영 완료 — 계약 3건 고정, 미규정 3건 단언 금지 준수 | From 6edf7f9dfff7597135a39b007b1586e6bf627043 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 4 Jun 2026 16:25:30 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test(react):=20add=20prepare=20runtime=20co?= =?UTF-8?q?ntract=20specs=20(FEP-2357=20A=C2=B7B=C2=B7C=C2=B7E=C2=B7F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translates FEP-2357-TEST-PLAN.md items A2-A9, B1-B3, C1-C2, E1-E10 and F1-F2 into Jest specs against the public entry (./index). `prepare` is not implemented yet, so all 25 tests fail with "prepare is not a function" — verified red for the right reason by temporarily wiring a reference implementation (all green) and reverting it. Register augmentation uses optional params only ({ id?: string }); registering required params breaks package-internal typecheck variance in stackflow.tsx/useStepFlow.ts. Because Register merges globally, every stackflow() call passes a complete components map via baseComponents spread. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/react/src/prepare.spec.tsx | 929 ++++++++++++++++++++++++ 1 file changed, 929 insertions(+) create mode 100644 integrations/react/src/prepare.spec.tsx diff --git a/integrations/react/src/prepare.spec.tsx b/integrations/react/src/prepare.spec.tsx new file mode 100644 index 000000000..7f30aee33 --- /dev/null +++ b/integrations/react/src/prepare.spec.tsx @@ -0,0 +1,929 @@ +/** + * FEP-2357 — `prepare` 런타임 규약 (FEP-2357-TEST-PLAN.md §3의 A·B·C·E·F) + * + * - A1은 prepare.types.spec.tsx에 배치한다 (계획서 §1). + * - D(usePrepare 래퍼 동등성)는 usePrepare.spec.tsx에 있다. + * - 스펙이 명시적 미규정으로 남긴 동작(loader 디듀프, chunk 중복 발사, + * 부분 발사 원자성/취소)은 어느 방향으로도 단언하지 않는다 (계획서 §5). + * - import는 public entry(`./index`)에서만 한다 (계획서 §0 import 경계). + */ +import { defineConfig } from "@stackflow/config"; +import type { Stack as CoreStack } from "@stackflow/core"; +import { act, render, screen } from "@testing-library/react"; +import React from "react"; +import type { StackflowReactPlugin } from "./index"; +import { + content, + lazy, + stackflow, + structuredActivityComponent, + useLoaderData, +} from "./index"; + +/** + * `Register` 증강은 패키지 전역으로 병합되므로, 모든 spec 파일이 동일한 + * 멤버를 선언한다(동일 타입 재선언은 declaration merging으로 허용된다). + * 이름 충돌 방지를 위해 `Prepare` 접두사를 사용한다 (계획서 §1). + * + * 주의: 필수 params(예: `{ id: string }`)를 등록하면 패키지 내부 소스 + * (`stackflow.tsx`의 ActivityComponentMapProvider, `useStepFlow.ts`)의 + * variance 검사가 깨져 typecheck가 영구히 실패하므로, in-package spec에서는 + * 옵셔널 params만 사용한다. + */ +declare module "@stackflow/config" { + interface Register { + PrepareActivityA: { id?: string }; + PrepareActivityB: { id?: string }; + PrepareHomeActivity: {}; + PrepareStructuredActivity: {}; + } +} + +type ActivityModule = { default: () => JSX.Element }; + +/** 제어 가능한 비동기 작업 (계획서 §2) */ +function createDeferred(): { + promise: Promise; + resolve: (v: T) => void; + reject: (e: unknown) => void; +} { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** 매크로태스크 한 턴을 대기해 그 시점까지 쌓인 마이크로태스크를 모두 비운다. */ +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * pending 검사: then-플래그 + 마이크로태스크 flush. + * Promise 내부 구조에 의존하지 않는다 (계획서 §2). + */ +async function isSettled(p: Promise): Promise { + let settled = false; + p.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await flushMicrotasks(); + return settled; +} + +/** + * 인라인 렌더러 플러그인 — `@stackflow/plugin-renderer-basic`은 워크스페이스 + * 순환 의존이라 사용할 수 없다 (계획서 §0). + */ +const testRendererPlugin: StackflowReactPlugin = () => ({ + key: "test-renderer", + render({ stack }) { + return ( + <> + {stack.render().activities.map((activity) => ( + + {activity.render()} + + ))} + + ); + }, +}); + +/** + * E4용 Suspense 래핑 변형 — lazy 컴포넌트가 pending chunk에서 suspend하므로 + * ``으로 감싼다 (계획서 §2). + */ +const suspenseTestRendererPlugin: StackflowReactPlugin = () => ({ + key: "test-renderer", + render({ stack }) { + return ( + suspense-fallback}> + {stack.render().activities.map((activity) => ( + + {activity.render()} + + ))} + + ); + }, +}); + +function PlainActivity() { + return
plain
; +} + +/** + * `Register`에 등록된 모든 이름은 `stackflow()`의 `components`에 키로 존재해야 + * 하므로(증강이 전역 병합되는 데 따른 타입 제약), 모든 호출은 이 기본 맵을 + * 스프레드한 뒤 테스트 대상 항목만 덮어쓴다. + */ +const baseComponents = { + PrepareActivityA: PlainActivity, + PrepareActivityB: PlainActivity, + PrepareHomeActivity: PlainActivity, + PrepareStructuredActivity: PlainActivity, +}; + +describe("prepare — stackflow() 출력", () => { + describe("A. 기본 규약 (렌더 없이 호출)", () => { + it("A2. params 생략 시 component chunk 로드만 발사하고 data loader는 호출하지 않는다", async () => { + // given: loader와 lazy 컴포넌트(import jest.fn)가 설정된 activity + const loader = jest.fn(() => ({ data: "x" })); + const importFn = jest.fn(() => + Promise.resolve({ default: () =>
A content
}), + ); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA", loader }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + }); + + // when: params 없이 호출한다 + await prepare("PrepareActivityA"); + + // then: import 함수는 호출되고, loader는 호출되지 않는다 + expect(importFn).toHaveBeenCalled(); + expect(loader).not.toHaveBeenCalled(); + }); + + it("A3. params 전달 시 chunk 로드와 data loader를 모두 발사한다", async () => { + // given: loader + lazy 컴포넌트(import jest.fn)인 activity + const loader = jest.fn(() => ({ data: "x" })); + const importFn = jest.fn(() => + Promise.resolve({ default: () =>
A content
}), + ); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA", loader }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + }); + + // when: params를 전달해 호출한다 + await prepare("PrepareActivityA", { id: "1" }); + + // then: loader가 공개 타입 ActivityLoaderArgs({ params, config }) 형태의 + // 인자로 호출되고, import 함수도 호출된다 + expect(loader).toHaveBeenCalledWith( + expect.objectContaining({ + params: { id: "1" }, + config: expect.anything(), + }), + ); + expect(importFn).toHaveBeenCalled(); + }); + + it("A4. loader가 없는 activity에 params를 전달해도 에러 없이 resolve된다", async () => { + // given: loader 없는 config + lazy 컴포넌트 + const importFn = jest.fn(() => + Promise.resolve({ default: () =>
A content
}), + ); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA" }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + }); + + // when: params를 전달해 호출한다 + const p = prepare("PrepareActivityA", { id: "1" }); + + // then: 반환 Promise가 에러 없이 resolve된다 (chunk 발사 검증은 A2의 규약) + await expect(p).resolves.toBeUndefined(); + }); + + it("A5. lazy도 structured도 아닌 일반 컴포넌트는 아무 작업도 발사하지 않고 resolve된다", async () => { + // given: 일반 함수 컴포넌트, loader 없는 activity + const config = defineConfig({ + activities: [{ name: "PrepareHomeActivity" }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents }, + }); + + // when: 호출한다 + const p = prepare("PrepareHomeActivity"); + + // then: 반환 Promise가 에러 없이 resolve된다 + await expect(p).resolves.toBeUndefined(); + }); + + it("A6. structuredActivityComponent의 dynamic content는 content import를 발사한다", async () => { + // given: content가 dynamic import 함수인 structured component + const contentImportFn = jest.fn(() => + Promise.resolve({ + default: content<"PrepareStructuredActivity">(() => ( +
structured content
+ )), + }), + ); + const config = defineConfig({ + activities: [{ name: "PrepareStructuredActivity" }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { + ...baseComponents, + PrepareStructuredActivity: + structuredActivityComponent<"PrepareStructuredActivity">({ + content: contentImportFn, + }), + }, + }); + + // when: 호출한다 + await prepare("PrepareStructuredActivity"); + + // then: content import 함수가 호출된다 + expect(contentImportFn).toHaveBeenCalled(); + }); + + it("A7. structuredActivityComponent의 정적 content는 추가 로드 없이 resolve된다", async () => { + // given: content가 함수가 아닌 정적 값인 structured component + const config = defineConfig({ + activities: [{ name: "PrepareStructuredActivity" }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { + ...baseComponents, + PrepareStructuredActivity: + structuredActivityComponent<"PrepareStructuredActivity">({ + content: content<"PrepareStructuredActivity">(() => ( +
structured content
+ )), + }), + }, + }); + + // when: 호출한다 + const p = prepare("PrepareStructuredActivity"); + + // then: 반환 Promise가 에러 없이 resolve된다 + // (동적 import 함수가 없으므로 호출 검증 대상도 없음) + await expect(p).resolves.toBeUndefined(); + }); + + it("A8. 미등록 activity 이름으로 호출하면 `Activity is not registered.` 에러로 reject된다", async () => { + // given: 등록된 activity만 있는 stackflow 인스턴스 + const config = defineConfig({ + activities: [{ name: "PrepareHomeActivity" }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents }, + }); + + // when: 미등록 이름으로 호출한다 (타입은 G1이 컴파일 타임에 차단하므로 + // 런타임 테스트는 as any로 우회한다 — 계획서 §2) + // 동기 throw라면 이 줄에서 테스트가 실패하므로, 아래 단언이 + // "throw가 아닌 reject" 계약을 함께 고정한다 + const p = prepare("Unknown" as any); + + // then: 해당 메시지의 Error로 reject된다 + await expect(p).rejects.toThrow("Activity Unknown is not registered."); + }); + + it('A9. 빈 객체 params도 "params 전달"로 취급되어 loader가 호출된다', async () => { + // given: 파라미터가 없는({} 타입) activity + loader + const loader = jest.fn(() => ({ data: "x" })); + const config = defineConfig({ + activities: [{ name: "PrepareHomeActivity", loader }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents }, + }); + + // when: 빈 객체 params로 호출한다 + await prepare("PrepareHomeActivity", {}); + + // then: loader가 호출된다 (생략한 경우(A2)와 달리) + expect(loader).toHaveBeenCalled(); + }); + }); + + describe("B. 반환 Promise 의미 — 모든 작업 완료 시에만 resolve", () => { + it("B1. chunk 로드가 완료되기 전에는 resolve되지 않고, 완료되면 resolve된다", async () => { + // given: deferred로 제어되는 lazy import 함수 + const chunkDeferred = createDeferred(); + const importFn = jest.fn(() => chunkDeferred.promise); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA" }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + }); + + // when: 호출 후 마이크로태스크를 flush한다 + const p = prepare("PrepareActivityA"); + + // then: 아직 settle되지 않았다 + expect(await isSettled(p)).toBe(false); + + // when: chunk 로드를 완료한다 + chunkDeferred.resolve({ default: () =>
A content
}); + + // then: resolve된다 + await expect(p).resolves.toBeUndefined(); + }); + + it("B2. loader만 완료되고 chunk가 미완료인 동안에는 resolve되지 않는다 (중간 상태 미노출)", async () => { + // given: loader와 lazy import 각각을 제어하는 deferred 2개 + const loaderDeferred = createDeferred<{ data: string }>(); + const chunkDeferred = createDeferred(); + const loader = jest.fn(() => loaderDeferred.promise); + const importFn = jest.fn(() => chunkDeferred.promise); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA", loader }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + }); + + // when: 호출 후 loader만 완료한다 + const p = prepare("PrepareActivityA", { id: "1" }); + loaderDeferred.resolve({ data: "loaded" }); + + // then: 여전히 pending이다 + expect(await isSettled(p)).toBe(false); + + // when: chunk 로드도 완료한다 + chunkDeferred.resolve({ default: () =>
A content
}); + + // then: resolve된다 + await expect(p).resolves.toBeUndefined(); + }); + + it("B3. chunk만 완료되고 loader가 미완료인 동안에는 resolve되지 않는다 (B2의 대칭)", async () => { + // given: B2와 동일한 픽스처 + const loaderDeferred = createDeferred<{ data: string }>(); + const chunkDeferred = createDeferred(); + const loader = jest.fn(() => loaderDeferred.promise); + const importFn = jest.fn(() => chunkDeferred.promise); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA", loader }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + }); + + // when: 호출 후 chunk만 완료한다 + const p = prepare("PrepareActivityA", { id: "1" }); + chunkDeferred.resolve({ default: () =>
A content
}); + + // then: 여전히 pending이다 + expect(await isSettled(p)).toBe(false); + + // when: loader도 완료한다 + loaderDeferred.resolve({ data: "loaded" }); + + // then: resolve된다 + await expect(p).resolves.toBeUndefined(); + }); + }); + + describe("C. React 밖 / 렌더 전 호출 가능성", () => { + it("C1. 렌더 없이(React 트리 부재) prepare가 완전한 동작을 한다", async () => { + // given: stackflow() 호출 직후, 어떤 컴포넌트도 렌더하지 않은 상태 + // (loader + lazy activity) + const loader = jest.fn(() => ({ data: "x" })); + const importFn = jest.fn(() => + Promise.resolve({ default: () =>
A content
}), + ); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA", loader }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + }); + + // when: 렌더 없이 호출한다 + await prepare("PrepareActivityA", { id: "1" }); + + // then: loader와 import 함수가 모두 호출된다 + expect(loader).toHaveBeenCalled(); + expect(importFn).toHaveBeenCalled(); + }); + + it("C2. 렌더 전 prepare 호출이 이후 마운트를 방해하지 않는다", async () => { + // given: lazy activity에 대한 prepare 완료, initialActivity는 일반 컴포넌트 + function HomeActivity() { + return
home
; + } + const importFn = jest.fn(() => + Promise.resolve({ default: () =>
A content
}), + ); + const config = defineConfig({ + activities: [ + { name: "PrepareHomeActivity" }, + { name: "PrepareActivityA" }, + ], + transitionDuration: 0, + initialActivity: () => "PrepareHomeActivity", + }); + const { Stack, prepare } = stackflow({ + config, + components: { + ...baseComponents, + PrepareHomeActivity: HomeActivity, + PrepareActivityA: lazy(importFn), + }, + plugins: [testRendererPlugin], + }); + await prepare("PrepareActivityA"); + + // when: 을 마운트한다 + render(); + + // then: 초기 activity가 정상 렌더된다 + expect(screen.getByText("home")).toBeTruthy(); + }); + }); + + describe("E. 동시성 · 경쟁 상태 · 실패", () => { + it("E1. 동일 activity에 대한 동시 중복 prepare — 두 Promise 모두 작업 완료 후 각각 resolve된다", async () => { + // given: deferred chunk를 가진 lazy activity. import 함수는 호출마다 + // 동일한 deferred.promise를 반환한다(디듀프-불가지 픽스처 — 계획서 §2) + const chunkDeferred = createDeferred(); + const importFn = jest.fn(() => chunkDeferred.promise); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA" }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + }); + + // when: 동시에 두 번 호출한다 + const p1 = prepare("PrepareActivityA"); + const p2 = prepare("PrepareActivityA"); + + // then: 둘 다 pending이다 + expect(await isSettled(p1)).toBe(false); + expect(await isSettled(p2)).toBe(false); + + // when: chunk 로드를 완료한다 + chunkDeferred.resolve({ default: () =>
A content
}); + + // then: 두 Promise 모두 resolve된다 + // (import 함수/loader의 호출 횟수는 단언하지 않는다 — 스펙 미규정, 계획서 §5) + await expect(p1).resolves.toBeUndefined(); + await expect(p2).resolves.toBeUndefined(); + }); + + it("E2. 서로 다른 activity의 동시 prepare는 서로 간섭하지 않는다", async () => { + // given: 각각 deferred chunk를 가진 lazy activity 2개 + const chunkADeferred = createDeferred(); + const chunkBDeferred = createDeferred(); + const importAFn = jest.fn(() => chunkADeferred.promise); + const importBFn = jest.fn(() => chunkBDeferred.promise); + const config = defineConfig({ + activities: [ + { name: "PrepareActivityA" }, + { name: "PrepareActivityB" }, + ], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { + ...baseComponents, + PrepareActivityA: lazy(importAFn), + PrepareActivityB: lazy(importBFn), + }, + }); + + // when: 둘을 동시에 호출한 뒤 B의 chunk만 완료한다 + const pA = prepare("PrepareActivityA"); + const pB = prepare("PrepareActivityB"); + chunkBDeferred.resolve({ default: () =>
B content
}); + + // then: pB는 resolve되고 pA는 여전히 pending이다 + await expect(pB).resolves.toBeUndefined(); + expect(await isSettled(pA)).toBe(false); + + // when: A의 chunk도 완료한다 + chunkADeferred.resolve({ default: () =>
A content
}); + + // then: pA도 resolve된다 + await expect(pA).resolves.toBeUndefined(); + }); + + it("E3. prepare 진행 중 같은 activity로 push가 발생해도 push는 정상 완료된다", async () => { + // given: 렌더(initial: 일반 Home), deferred chunk의 lazy activity, + // spy 플러그인(getStack), 미완료 prepare 발사 + let getStack!: () => CoreStack; + const spyPlugin: StackflowReactPlugin = () => ({ + key: "spy", + onInit({ actions }) { + getStack = actions.getStack; + }, + }); + function HomeActivity() { + return
home
; + } + const chunkDeferred = createDeferred(); + const importFn = jest.fn(() => chunkDeferred.promise); + const config = defineConfig({ + activities: [ + { name: "PrepareHomeActivity" }, + { name: "PrepareActivityA" }, + ], + transitionDuration: 0, + initialActivity: () => "PrepareHomeActivity", + }); + const { Stack, actions, prepare } = stackflow({ + config, + components: { + ...baseComponents, + PrepareHomeActivity: HomeActivity, + PrepareActivityA: lazy(importFn), + }, + plugins: [testRendererPlugin, spyPlugin], + }); + render(); + const activitiesBefore = getStack().activities; + const p = prepare("PrepareActivityA"); + + // when: 같은 activity로 push한 뒤 chunk를 완료하고 settle을 기다린다 + await act(async () => { + actions.push("PrepareActivityA", {}); + }); + await act(async () => { + chunkDeferred.resolve({ default: () =>
A content
}); + await p; + await flushMicrotasks(); + }); + + // then: 스택이 기존 + 1개가 되고 top이 해당 activity다 + const activities = getStack().activities; + expect(activities).toHaveLength(activitiesBefore.length + 1); + expect(activities[activities.length - 1].name).toBe("PrepareActivityA"); + expect(activities[activities.length - 1].enteredBy.name).toBe("Pushed"); + }); + + it("E4. prepare 진행 중 마운트(부트스트랩 시나리오)도 정상 동작한다", async () => { + // given: deferred chunk의 lazy activity(loader 없음)가 initialActivity, + // Suspense 래핑 인라인 렌더러, prepare 발사 직후(미완료) + const chunkDeferred = createDeferred(); + const importFn = jest.fn(() => chunkDeferred.promise); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA" }], + transitionDuration: 0, + initialActivity: () => "PrepareActivityA", + }); + const { Stack, prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + plugins: [suspenseTestRendererPlugin], + }); + const p = prepare("PrepareActivityA"); + + // when: 을 마운트한 뒤 chunk를 완료하고 settle을 기다린다 + render(); + await act(async () => { + chunkDeferred.resolve({ default: () =>
A content
}); + await p; + await flushMicrotasks(); + }); + + // then: 해당 activity의 콘텐츠가 렌더된다 + expect(await screen.findByText("A content")).toBeTruthy(); + }); + + it("E5. loader가 동기 throw하면 반환 Promise는 해당 에러로 reject된다", async () => { + // given: 동기 throw하는 loader인 activity (+ lazy 컴포넌트) + const err = new Error("loader sync throw"); + const loader = jest.fn(() => { + throw err; + }); + const importFn = jest.fn(() => + Promise.resolve({ default: () =>
A content
}), + ); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA", loader }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + }); + + // when: params와 함께 호출한다 (동기 throw로 전파된다면 이 줄에서 실패한다) + const p = prepare("PrepareActivityA", { id: "1" }); + + // then: 해당 에러로 reject된다 + // (chunk 발사 여부는 단언하지 않는다 — 부분 발사 원자성은 스펙 미규정, 계획서 §5) + await expect(p).rejects.toBe(err); + }); + + it("E6. loader가 비동기 reject하면 반환 Promise는 해당 reason으로 reject된다", async () => { + // given: reject하는 loader인 activity + const err = new Error("loader async reject"); + const loader = jest.fn(() => Promise.reject(err)); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA", loader }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents }, + }); + + // when: params와 함께 호출한다 + const p = prepare("PrepareActivityA", { id: "1" }); + + // then: 해당 reason으로 reject된다 + await expect(p).rejects.toBe(err); + }); + + it("E7. chunk 로드가 reject하면 반환 Promise는 해당 reason으로 reject된다", async () => { + // given: import가 reject하는 lazy activity + const err = new Error("chunk load failed"); + const importFn = jest.fn(() => Promise.reject(err)); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA" }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + }); + + // when: 호출한다 + const p = prepare("PrepareActivityA"); + + // then: 해당 reason으로 reject된다 + await expect(p).rejects.toBe(err); + }); + + it("E8. chunk 로드 실패 후 같은 activity를 다시 prepare하면 로드를 재시도한다", async () => { + // given: 첫 호출은 reject, 두 번째 호출은 resolve하는 lazy import + const err = new Error("chunk load failed"); + const importFn = jest + .fn, []>() + .mockRejectedValueOnce(err) + .mockResolvedValueOnce({ default: () =>
A content
}); + const config = defineConfig({ + activities: [{ name: "PrepareActivityA" }], + transitionDuration: 0, + }); + const { prepare } = stackflow({ + config, + components: { ...baseComponents, PrepareActivityA: lazy(importFn) }, + }); + + // given: 첫 prepare의 reject를 확인한다 + await expect(prepare("PrepareActivityA")).rejects.toBe(err); + + // when: 같은 activity를 다시 prepare한다 + const p2 = prepare("PrepareActivityA"); + + // then: import 함수가 다시 호출되고(총 2회) p2는 resolve된다 + // (재호출이 곧 "재시도" 계약의 직접 관찰이다 — 캐시된 실패가 + // 반환되면 p2가 reject되어 구분된다) + await expect(p2).resolves.toBeUndefined(); + expect(importFn).toHaveBeenCalledTimes(2); + }); + + it("E9. prepare 실패가 이후 내비게이션과 다른 prepare를 오염시키지 않는다 (오류 격리 invariant)", async () => { + // given: loader가 reject하는 A, 정상 lazy + loader의 B, + // 렌더 + spy 플러그인 + let getStack!: () => CoreStack; + const spyPlugin: StackflowReactPlugin = () => ({ + key: "spy", + onInit({ actions }) { + getStack = actions.getStack; + }, + }); + function HomeActivity() { + return
home
; + } + const err = new Error("A loader failed"); + const loaderA = jest.fn(() => Promise.reject(err)); + const loaderB = jest.fn(() => ({ data: "b" })); + const importBFn = jest.fn(() => + Promise.resolve({ default: () =>
B content
}), + ); + const config = defineConfig({ + activities: [ + { name: "PrepareHomeActivity" }, + { name: "PrepareActivityA", loader: loaderA }, + { name: "PrepareActivityB", loader: loaderB }, + ], + transitionDuration: 0, + initialActivity: () => "PrepareHomeActivity", + }); + const { Stack, actions, prepare } = stackflow({ + config, + components: { + ...baseComponents, + PrepareHomeActivity: HomeActivity, + PrepareActivityB: lazy(importBFn), + }, + plugins: [testRendererPlugin, spyPlugin], + }); + render(); + + // given: A에 대한 prepare의 reject를 확인한다 + await expect(prepare("PrepareActivityA", { id: "a" })).rejects.toBe(err); + + // when: B를 prepare한 뒤 B로 push한다 + const pB = prepare("PrepareActivityB", { id: "b" }); + + // then: B의 prepare는 resolve된다 + await expect(pB).resolves.toBeUndefined(); + + // when: B로 push한다 + await act(async () => { + actions.push("PrepareActivityB", { id: "b" }); + await flushMicrotasks(); + }); + + // then: 스택 top이 B다 + const activities = getStack().activities; + expect(activities[activities.length - 1].name).toBe("PrepareActivityB"); + }); + + it("E10. prepare는 스택 상태를 변경하지 않으며 내비게이션 이벤트를 발생시키지 않는다", async () => { + // given: 렌더, spy 플러그인(getStack + onChanged/onBeforePush/onPushed + // 기록), loader + lazy의 activity + let getStack!: () => CoreStack; + const onChanged = jest.fn(); + const onBeforePush = jest.fn(); + const onPushed = jest.fn(); + const spyPlugin: StackflowReactPlugin = () => ({ + key: "spy", + onInit({ actions }) { + getStack = actions.getStack; + }, + onChanged, + onBeforePush, + onPushed, + }); + function HomeActivity() { + return
home
; + } + const loader = jest.fn(() => ({ data: "x" })); + const importFn = jest.fn(() => + Promise.resolve({ default: () =>
A content
}), + ); + const config = defineConfig({ + activities: [ + { name: "PrepareHomeActivity" }, + { name: "PrepareActivityA", loader }, + ], + transitionDuration: 0, + initialActivity: () => "PrepareHomeActivity", + }); + const { Stack, prepare } = stackflow({ + config, + components: { + ...baseComponents, + PrepareHomeActivity: HomeActivity, + PrepareActivityA: lazy(importFn), + }, + plugins: [testRendererPlugin, spyPlugin], + }); + render(); + + // given: 스택 스냅샷과 훅 호출 횟수를 채취한다 + const activitiesBefore = getStack().activities; + const onChangedCallsBefore = onChanged.mock.calls.length; + const onBeforePushCallsBefore = onBeforePush.mock.calls.length; + const onPushedCallsBefore = onPushed.mock.calls.length; + + // when: prepare를 완료한 뒤 재채취한다 + await prepare("PrepareActivityA", { id: "1" }); + await flushMicrotasks(); + + // then: 스택이 prepare 전후 동등하고, 기록된 플러그인 훅이 prepare로 + // 인해 추가 호출되지 않았다 (두 단언 모두 "core store 미접촉"이라는 + // 단일 규약의 관찰 지점이다) + expect(getStack().activities).toEqual(activitiesBefore); + expect(onChanged.mock.calls.length).toBe(onChangedCallsBefore); + expect(onBeforePush.mock.calls.length).toBe(onBeforePushCallsBefore); + expect(onPushed.mock.calls.length).toBe(onPushedCallsBefore); + }); + }); + + describe("F. loaderPlugin과의 책임 분리", () => { + // 주의: 이 절은 호출 횟수를 단언하지 않는다. loader 디듀프·chunk 중복 발사 + // 여부는 스펙 미규정(계획서 §5)이며, 여기서는 "prepare가 기존 내비게이션 + // 경로(loaderData 주입·lazy 렌더)를 방해하지 않는다"는 책임 분리만 검증한다. + + it("F1. prepare 후 push해도 loaderData 주입은 loaderPlugin 경로로 정상 동작한다", async () => { + // given: 동기 데이터를 반환하는 loader의 activity, + // 해당 컴포넌트는 useLoaderData() 값을 렌더. 렌더(initial: Home) + function HomeActivity() { + return
home
; + } + const loader = jest.fn(() => ({ message: "loaded" })); + function ActivityAWithLoaderData() { + const data = useLoaderData<() => { message: string }>(); + return
{data.message}
; + } + const config = defineConfig({ + activities: [ + { name: "PrepareHomeActivity" }, + { name: "PrepareActivityA", loader }, + ], + transitionDuration: 0, + initialActivity: () => "PrepareHomeActivity", + }); + const { Stack, actions, prepare } = stackflow({ + config, + components: { + ...baseComponents, + PrepareHomeActivity: HomeActivity, + PrepareActivityA: ActivityAWithLoaderData, + }, + plugins: [testRendererPlugin], + }); + render(); + + // when: prepare를 완료한 뒤 push하고 settle을 기다린다 + await prepare("PrepareActivityA", { id: "1" }); + await act(async () => { + actions.push("PrepareActivityA", { id: "1" }); + await flushMicrotasks(); + }); + + // then: activity가 loader 데이터와 함께 렌더된다 — prepare가 loaderData + // 주입 경로를 가로채거나 망가뜨리지 않는다 + expect(await screen.findByText("loaded")).toBeTruthy(); + }); + + it("F2. prepare 완료 후 push하면 lazy activity가 정상 렌더된다", async () => { + // given: resolve되는 lazy의 activity, 렌더(initial: Home) + function HomeActivity() { + return
home
; + } + const importFn = jest.fn(() => + Promise.resolve({ default: () =>
A content
}), + ); + const config = defineConfig({ + activities: [ + { name: "PrepareHomeActivity" }, + { name: "PrepareActivityA" }, + ], + transitionDuration: 0, + initialActivity: () => "PrepareHomeActivity", + }); + const { Stack, actions, prepare } = stackflow({ + config, + components: { + ...baseComponents, + PrepareHomeActivity: HomeActivity, + PrepareActivityA: lazy(importFn), + }, + plugins: [testRendererPlugin], + }); + render(); + + // when: prepare를 완료한 뒤 push하고 settle을 기다린다 + await prepare("PrepareActivityA"); + await act(async () => { + actions.push("PrepareActivityA", {}); + await flushMicrotasks(); + }); + + // then: activity의 콘텐츠가 렌더된다 — 워밍된 chunk가 이후 내비게이션 + // 렌더를 방해하지 않는다 + // (import 호출 횟수는 단언하지 않는다 — 스펙 미규정, 계획서 §5) + expect(await screen.findByText("A content")).toBeTruthy(); + }); + }); +}); From 12459f058f65576886e199e04d0fffcfe35db547 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 4 Jun 2026 16:25:30 +0900 Subject: [PATCH 4/6] test(react): add usePrepare wrapper equivalence specs (FEP-2357 D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D1-D2 verify the current usePrepare behavior that the new prepare must match (spec §3 "thin wrapper"). These run against existing code and pass today. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/react/src/usePrepare.spec.tsx | 126 +++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 integrations/react/src/usePrepare.spec.tsx diff --git a/integrations/react/src/usePrepare.spec.tsx b/integrations/react/src/usePrepare.spec.tsx new file mode 100644 index 000000000..54a5058c4 --- /dev/null +++ b/integrations/react/src/usePrepare.spec.tsx @@ -0,0 +1,126 @@ +/** + * FEP-2357 — `usePrepare` 래퍼 동등성 (FEP-2357-TEST-PLAN.md §3의 D) + * + * usePrepare가 반환한 함수는 stackflow() 출력 `prepare`와 동일한 관찰 결과를 + * 보여야 한다 (스펙 §3 "동일 로직을 감싸는 얇은 래퍼"). + * 이 절은 현행 동작 기준이므로 prepare 구현 이전에도 green이어야 한다. + * + * - import는 public entry(`./index`)에서만 한다 (계획서 §0 import 경계). + */ +import { defineConfig } from "@stackflow/config"; +import { render } from "@testing-library/react"; +import React from "react"; +import type { Prepare, StackflowReactPlugin } from "./index"; +import { lazy, stackflow, usePrepare } from "./index"; + +/** + * `Register` 증강은 패키지 전역으로 병합된다 — prepare.spec.tsx와 동일한 + * 멤버의 재선언이다(동일 타입 재선언은 declaration merging으로 허용된다). + */ +declare module "@stackflow/config" { + interface Register { + PrepareActivityA: { id?: string }; + PrepareActivityB: { id?: string }; + PrepareHomeActivity: {}; + PrepareStructuredActivity: {}; + } +} + +/** 인라인 렌더러 플러그인 (계획서 §0 — plugin-renderer-basic은 순환 의존) */ +const testRendererPlugin: StackflowReactPlugin = () => ({ + key: "test-renderer", + render({ stack }) { + return ( + <> + {stack.render().activities.map((activity) => ( + + {activity.render()} + + ))} + + ); + }, +}); + +function PlainActivity() { + return
plain
; +} + +/** Register에 등록된 모든 이름은 components에 키로 존재해야 한다. */ +const baseComponents = { + PrepareActivityA: PlainActivity, + PrepareActivityB: PlainActivity, + PrepareHomeActivity: PlainActivity, + PrepareStructuredActivity: PlainActivity, +}; + +describe("usePrepare — D. 래퍼 동등성", () => { + it("D1. usePrepare가 반환한 함수도 chunk + data를 동일하게 발사한다", async () => { + // given: 렌더 — 초기 activity 내부에서 usePrepare() 반환값을 + // 외부 변수로 캡처. 별도의 lazy + loader activity B. + let capturedPrepare!: Prepare; + function HomeActivity() { + capturedPrepare = usePrepare(); + return
home
; + } + const loader = jest.fn(() => ({ data: "b" })); + const importFn = jest.fn(() => + Promise.resolve({ default: () =>
B content
}), + ); + const config = defineConfig({ + activities: [ + { name: "PrepareHomeActivity" }, + { name: "PrepareActivityB", loader }, + ], + transitionDuration: 0, + initialActivity: () => "PrepareHomeActivity", + }); + const { Stack } = stackflow({ + config, + components: { + ...baseComponents, + PrepareHomeActivity: HomeActivity, + PrepareActivityB: lazy(importFn), + }, + plugins: [testRendererPlugin], + }); + render(); + + // when: 캡처한 함수로 params와 함께 호출한다 + await capturedPrepare("PrepareActivityB", { id: "1" }); + + // then: loader가 params 인자로 호출되고, import 함수가 호출된다 + // — A3과 동일한 관찰 결과 + expect(loader).toHaveBeenCalledWith( + expect.objectContaining({ params: { id: "1" } }), + ); + expect(importFn).toHaveBeenCalled(); + }); + + it("D2. usePrepare가 반환한 함수도 미등록 activity에 동일 에러로 reject된다", async () => { + // given: D1과 동일하게 캡처한 함수 + let capturedPrepare!: Prepare; + function HomeActivity() { + capturedPrepare = usePrepare(); + return
home
; + } + const config = defineConfig({ + activities: [{ name: "PrepareHomeActivity" }], + transitionDuration: 0, + initialActivity: () => "PrepareHomeActivity", + }); + const { Stack } = stackflow({ + config, + components: { ...baseComponents, PrepareHomeActivity: HomeActivity }, + plugins: [testRendererPlugin], + }); + render(); + + // when: 미등록 이름으로 호출한다 (타입은 G1이 컴파일 타임에 차단하므로 + // 런타임 테스트는 as any로 우회한다 — 계획서 §2) + const p = capturedPrepare("Unknown" as any); + + // then: A8과 동일한 에러로 reject된다 + await expect(p).rejects.toThrow("Activity Unknown is not registered."); + }); +}); From bfaa6f1fbb60b0e16067512a234b5144f4911d93 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 4 Jun 2026 16:25:30 +0900 Subject: [PATCH 5/6] test(react): add prepare type-safety specs (FEP-2357 G + A1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit G1-G4 are typecheck-only assertions placed in never-called function bodies (swc does not typecheck; runtime execution must be avoided). A1 lives here because Jest requires at least one test per spec file. Until prepare lands, `yarn workspace @stackflow/react typecheck` fails with TS2339 (Property 'prepare' does not exist on StackflowOutput) in both this file and prepare.spec.tsx — a single root cause. Verified that a typed reference implementation turns typecheck fully green. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/react/src/prepare.types.spec.tsx | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 integrations/react/src/prepare.types.spec.tsx diff --git a/integrations/react/src/prepare.types.spec.tsx b/integrations/react/src/prepare.types.spec.tsx new file mode 100644 index 000000000..211df61df --- /dev/null +++ b/integrations/react/src/prepare.types.spec.tsx @@ -0,0 +1,102 @@ +/** + * FEP-2357 — `prepare` 타입 안전성 (FEP-2357-TEST-PLAN.md §3의 G) + A1 + * + * - G절은 `yarn workspace @stackflow/react typecheck`(tsconfig.test.json)로 + * 검증된다. 모든 타입 단언은 절대 호출되지 않는 함수 본문 안에 배치한다 + * (@swc/jest는 타입을 검사하지 않으므로 런타임 실행을 막기 위함 — 계획서 §1). + * - `@ts-expect-error`는 "다음 줄에 컴파일 에러가 있어야 통과" 시맨틱이므로, + * 규약이 깨지면 typecheck가 실패한다. + * - Jest는 spec 파일에 최소 1개 테스트를 요구하므로 런타임 항목 A1을 이 + * 파일에 함께 둔다 (계획서 §1). + * - import는 public entry(`./index`)에서만 한다 (계획서 §0 import 경계). + * + * [TDD 상태 주의] `prepare`가 stackflow() 출력에 아직 없으므로, 이 파일은 + * 구현 전까지 `output.prepare` 접근(TS2339)과 그에 따른 `@ts-expect-error` + * 미발동(TS2578)으로 typecheck가 실패한다 — 모두 prepare 부재가 단일 + * 원인이며, 구현이 들어오면 전부 green이 되어야 한다. + */ +import { defineConfig } from "@stackflow/config"; +import type { Prepare, usePrepare } from "./index"; +import { stackflow } from "./index"; + +/** + * `Register` 증강은 패키지 전역으로 병합된다 — prepare.spec.tsx와 동일한 + * 멤버의 재선언이다(동일 타입 재선언은 declaration merging으로 허용된다). + */ +declare module "@stackflow/config" { + interface Register { + PrepareActivityA: { id?: string }; + PrepareActivityB: { id?: string }; + PrepareHomeActivity: {}; + PrepareStructuredActivity: {}; + } +} + +function PlainActivity() { + return
plain
; +} + +/** Register에 등록된 모든 이름은 components에 키로 존재해야 한다. */ +const baseComponents = { + PrepareActivityA: PlainActivity, + PrepareActivityB: PlainActivity, + PrepareHomeActivity: PlainActivity, + PrepareStructuredActivity: PlainActivity, +}; + +const config = defineConfig({ + activities: [{ name: "PrepareActivityA" }], + transitionDuration: 0, +}); + +const output = stackflow({ + config, + components: baseComponents, +}); + +describe("prepare — A. 기본 규약 (출력 형태)", () => { + it("A1. stackflow() 출력에 prepare 함수가 포함된다", () => { + // given: defineConfig + components로 stackflow()를 호출한다 (모듈 상단 픽스처) + // when: 반환 객체를 확인한다 + // then: prepare가 함수다 + expect(typeof output.prepare).toBe("function"); + }); +}); + +// --- G. 타입 안전성 --- +// 아래 함수들은 typecheck 전용이며 절대 호출되지 않는다. + +/** G1. 미등록 activity 이름은 컴파일 에러다 */ +function _typecheckG1() { + // @ts-expect-error Register에 증강되지 않은 이름은 거부된다 + output.prepare("NotRegistered"); +} + +/** G2. 잘못된 params 타입은 컴파일 에러다 */ +function _typecheckG2() { + // @ts-expect-error params 값 타입 불일치(string 자리에 number)는 거부된다 + output.prepare("PrepareActivityA", { id: 123 }); + // @ts-expect-error 정의되지 않은 params 키는 거부된다 + output.prepare("PrepareActivityA", { wrong: "x" }); +} + +/** G3. params는 생략 가능하고 반환 타입은 Promise다 */ +function _typecheckG3() { + const r1: Promise = output.prepare("PrepareActivityA"); + const r2: Promise = output.prepare("PrepareActivityA", { id: "1" }); + return [r1, r2]; +} + +/** + * G4. stackflow() 출력 prepare와 usePrepare 반환값은 모두 Prepare 타입과 + * 상호 할당 가능하다 — 두 진입점이 동일한 공개 시그니처를 공유한다 + */ +function _typecheckG4(up: ReturnType) { + // 정방향: 두 진입점 → Prepare + const a: Prepare = output.prepare; + const b: Prepare = up; + // 역방향: Prepare → 두 진입점의 타입 + const c: ReturnType = a; + const d: typeof output.prepare = b; + return [a, b, c, d]; +} From 51ad11d24eb53c77e3e6475828e4029dddea55e7 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 4 Jun 2026 16:25:30 +0900 Subject: [PATCH 6/6] test(react): drop harness smoke spec superseded by FEP-2357 specs The smoke spec invited removal once real specs cover the same ground; prepare.spec.tsx/usePrepare.spec.tsx now exercise the same harness surface (spec pickup, @swc/jest, jsdom + Testing Library, workspace deps, inline renderer plugin). Keeping it would also break typecheck: Register augmentation merges package-wide, so its stackflow() call would need components for every Prepare* activity registered by the new specs. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/react/src/harness.smoke.spec.tsx | 65 ------------------- 1 file changed, 65 deletions(-) delete mode 100644 integrations/react/src/harness.smoke.spec.tsx diff --git a/integrations/react/src/harness.smoke.spec.tsx b/integrations/react/src/harness.smoke.spec.tsx deleted file mode 100644 index 6df3a8f56..000000000 --- a/integrations/react/src/harness.smoke.spec.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Smoke test that verifies the test harness itself: - * - * - `.spec.tsx` files are picked up by Jest and transformed by `@swc/jest` - * - the `jsdom` environment and `@testing-library/react` work together - * - workspace dependencies (`@stackflow/config`, `@stackflow/core`) resolve - * - a minimal inline renderer plugin (public `render` API) renders activities, - * so specs do not need `@stackflow/plugin-renderer-basic` (which would - * create a workspace dependency cycle) - * - * Feel free to remove this file once real specs cover the same ground. - */ -import { defineConfig } from "@stackflow/config"; -import { render, screen } from "@testing-library/react"; -import React from "react"; -import type { StackflowReactPlugin } from "./index"; -import { stackflow } from "./index"; - -declare module "@stackflow/config" { - interface Register { - SmokeActivity: {}; - } -} - -const testRendererPlugin: StackflowReactPlugin = () => ({ - key: "test-renderer", - render({ stack }) { - return ( - <> - {stack.render().activities.map((activity) => ( - - {activity.render()} - - ))} - - ); - }, -}); - -describe("test harness", () => { - it("renders an activity through a minimal inline renderer plugin", () => { - // given - function SmokeActivity() { - return
smoke
; - } - - const config = defineConfig({ - activities: [{ name: "SmokeActivity" }], - transitionDuration: 0, - initialActivity: () => "SmokeActivity", - }); - - const { Stack } = stackflow({ - config, - components: { SmokeActivity }, - plugins: [testRendererPlugin], - }); - - // when - render(); - - // then - expect(screen.getByText("smoke")).toBeTruthy(); - }); -});