From 8fad69ae19a7b5054bd1fdc463afb704a6390240 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Thu, 4 Jun 2026 15:26:34 +0900 Subject: [PATCH 01/15] 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 02/15] 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 03/15] =?UTF-8?q?test(react):=20add=20prepare=20runtime=20?= =?UTF-8?q?contract=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 04/15] 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 05/15] 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 06/15] 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(); - }); -}); From ab3c359142884683cbd66c1af5ad6c308b91b042 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Sun, 7 Jun 2026 21:18:45 +0900 Subject: [PATCH 07/15] docs(react): fold FEP-2357 test rationale into spec comments, drop plan/spec docs Working code is the source of truth: test-case rationale now lives as self-contained comments in the spec files (contract summary + unspecified- behavior guardrails in headers, given/when/then per test), with provenance pointing to Linear FEP-2357. Process artifacts (harness setup, fixture sketches, self-audit checklists) belong in the PR/Linear, not the repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- FEP-2357-SPEC.md | 123 ------ FEP-2357-TEST-PLAN.md | 356 ------------------ integrations/react/src/prepare.spec.tsx | 53 ++- integrations/react/src/prepare.types.spec.tsx | 12 +- integrations/react/src/usePrepare.spec.tsx | 14 +- 5 files changed, 51 insertions(+), 507 deletions(-) delete mode 100644 FEP-2357-SPEC.md delete mode 100644 FEP-2357-TEST-PLAN.md diff --git a/FEP-2357-SPEC.md b/FEP-2357-SPEC.md deleted file mode 100644 index ec735a84a..000000000 --- a/FEP-2357-SPEC.md +++ /dev/null @@ -1,123 +0,0 @@ -# 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 deleted file mode 100644 index d617e7f0e..000000000 --- a/FEP-2357-TEST-PLAN.md +++ /dev/null @@ -1,356 +0,0 @@ -# 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건 단언 금지 준수 | diff --git a/integrations/react/src/prepare.spec.tsx b/integrations/react/src/prepare.spec.tsx index 7f30aee33..26c0ff67c 100644 --- a/integrations/react/src/prepare.spec.tsx +++ b/integrations/react/src/prepare.spec.tsx @@ -1,11 +1,27 @@ /** - * FEP-2357 — `prepare` 런타임 규약 (FEP-2357-TEST-PLAN.md §3의 A·B·C·E·F) + * FEP-2357 — `prepare` 런타임 규약 (Linear FEP-2357) * - * - A1은 prepare.types.spec.tsx에 배치한다 (계획서 §1). - * - D(usePrepare 래퍼 동등성)는 usePrepare.spec.tsx에 있다. - * - 스펙이 명시적 미규정으로 남긴 동작(loader 디듀프, chunk 중복 발사, - * 부분 발사 원자성/취소)은 어느 방향으로도 단언하지 않는다 (계획서 §5). - * - import는 public entry(`./index`)에서만 한다 (계획서 §0 import 경계). + * `stackflow()` 출력의 `prepare(activityName, activityParams?)`는 React 렌더링 + * 트리 밖에서(렌더 이전 포함) activity component chunk와 data loader를 미리 + * 발사한다. 이 파일이 고정하는 계약: + * + * - A. params 생략 → chunk만, params 전달 → chunk + loader 발사 + * - B. 반환 Promise는 발사한 모든 작업 완료 시에만 resolve (중간 상태 미노출) + * - C. React 트리 부재 상태에서 완전 동작하고 이후 마운트를 방해하지 않음 + * - E. 모든 실패는 동기 throw가 아닌 원본 reason 그대로의 reject로 전달되고, + * chunk 실패는 재-prepare 시 재시도되며, prepare는 core store를 건드리지 + * 않는다(스택 상태·내비게이션 이벤트 불변) + * - F. loaderData 주입·lazy 렌더 등 기존 내비게이션 경로와의 책임 분리 + * + * 스펙이 구현 상세로 남긴 동작(loader 디듀프, chunk import 중복 발사, 부분 발사 + * 원자성/취소)은 어느 방향으로도 단언하지 않는다 — "스펙 미규정"으로 표기. + * + * usePrepare 래퍼 동등성(D)은 usePrepare.spec.tsx에, 타입 안전성(G)과 A1은 + * prepare.types.spec.tsx에 있다. + * + * import는 public entry(`./index`)에서만 한다 — `"@stackflow/react"` 패키지명 + * import는 dist(빌드 산출물)를 가리키므로 src 변경 대신 stale artifact를 + * 검증하게 된다. */ import { defineConfig } from "@stackflow/config"; import type { Stack as CoreStack } from "@stackflow/core"; @@ -23,7 +39,7 @@ import { /** * `Register` 증강은 패키지 전역으로 병합되므로, 모든 spec 파일이 동일한 * 멤버를 선언한다(동일 타입 재선언은 declaration merging으로 허용된다). - * 이름 충돌 방지를 위해 `Prepare` 접두사를 사용한다 (계획서 §1). + * 다른 spec과의 이름 충돌 방지를 위해 `Prepare` 접두사를 사용한다. * * 주의: 필수 params(예: `{ id: string }`)를 등록하면 패키지 내부 소스 * (`stackflow.tsx`의 ActivityComponentMapProvider, `useStepFlow.ts`)의 @@ -41,7 +57,7 @@ declare module "@stackflow/config" { type ActivityModule = { default: () => JSX.Element }; -/** 제어 가능한 비동기 작업 (계획서 §2) */ +/** 제어 가능한 비동기 작업 */ function createDeferred(): { promise: Promise; resolve: (v: T) => void; @@ -63,7 +79,7 @@ function flushMicrotasks(): Promise { /** * pending 검사: then-플래그 + 마이크로태스크 flush. - * Promise 내부 구조에 의존하지 않는다 (계획서 §2). + * Promise 내부 구조에 의존하지 않는다. */ async function isSettled(p: Promise): Promise { let settled = false; @@ -81,7 +97,7 @@ async function isSettled(p: Promise): Promise { /** * 인라인 렌더러 플러그인 — `@stackflow/plugin-renderer-basic`은 워크스페이스 - * 순환 의존이라 사용할 수 없다 (계획서 §0). + * 순환 의존이라 사용할 수 없다. */ const testRendererPlugin: StackflowReactPlugin = () => ({ key: "test-renderer", @@ -100,7 +116,7 @@ const testRendererPlugin: StackflowReactPlugin = () => ({ /** * E4용 Suspense 래핑 변형 — lazy 컴포넌트가 pending chunk에서 suspend하므로 - * ``으로 감싼다 (계획서 §2). + * ``으로 감싼다. */ const suspenseTestRendererPlugin: StackflowReactPlugin = () => ({ key: "test-renderer", @@ -296,7 +312,7 @@ describe("prepare — stackflow() 출력", () => { }); // when: 미등록 이름으로 호출한다 (타입은 G1이 컴파일 타임에 차단하므로 - // 런타임 테스트는 as any로 우회한다 — 계획서 §2) + // 런타임 테스트는 as any로 우회한다) // 동기 throw라면 이 줄에서 테스트가 실패하므로, 아래 단언이 // "throw가 아닌 reject" 계약을 함께 고정한다 const p = prepare("Unknown" as any); @@ -474,7 +490,8 @@ describe("prepare — stackflow() 출력", () => { describe("E. 동시성 · 경쟁 상태 · 실패", () => { it("E1. 동일 activity에 대한 동시 중복 prepare — 두 Promise 모두 작업 완료 후 각각 resolve된다", async () => { // given: deferred chunk를 가진 lazy activity. import 함수는 호출마다 - // 동일한 deferred.promise를 반환한다(디듀프-불가지 픽스처 — 계획서 §2) + // 동일한 deferred.promise를 반환한다 — 구현이 디듀프하든 안 하든 + // 테스트 결과가 같도록(디듀프-불가지 픽스처) const chunkDeferred = createDeferred(); const importFn = jest.fn(() => chunkDeferred.promise); const config = defineConfig({ @@ -498,7 +515,7 @@ describe("prepare — stackflow() 출력", () => { chunkDeferred.resolve({ default: () =>
A content
}); // then: 두 Promise 모두 resolve된다 - // (import 함수/loader의 호출 횟수는 단언하지 않는다 — 스펙 미규정, 계획서 §5) + // (import 함수/loader의 호출 횟수는 단언하지 않는다 — 스펙 미규정) await expect(p1).resolves.toBeUndefined(); await expect(p2).resolves.toBeUndefined(); }); @@ -645,7 +662,7 @@ describe("prepare — stackflow() 출력", () => { const p = prepare("PrepareActivityA", { id: "1" }); // then: 해당 에러로 reject된다 - // (chunk 발사 여부는 단언하지 않는다 — 부분 발사 원자성은 스펙 미규정, 계획서 §5) + // (chunk 발사 여부는 단언하지 않는다 — 부분 발사 원자성은 스펙 미규정) await expect(p).rejects.toBe(err); }); @@ -841,8 +858,8 @@ describe("prepare — stackflow() 출력", () => { describe("F. loaderPlugin과의 책임 분리", () => { // 주의: 이 절은 호출 횟수를 단언하지 않는다. loader 디듀프·chunk 중복 발사 - // 여부는 스펙 미규정(계획서 §5)이며, 여기서는 "prepare가 기존 내비게이션 - // 경로(loaderData 주입·lazy 렌더)를 방해하지 않는다"는 책임 분리만 검증한다. + // 여부는 스펙 미규정이며, 여기서는 "prepare가 기존 내비게이션 경로 + // (loaderData 주입·lazy 렌더)를 방해하지 않는다"는 책임 분리만 검증한다. it("F1. prepare 후 push해도 loaderData 주입은 loaderPlugin 경로로 정상 동작한다", async () => { // given: 동기 데이터를 반환하는 loader의 activity, @@ -922,7 +939,7 @@ describe("prepare — stackflow() 출력", () => { // then: activity의 콘텐츠가 렌더된다 — 워밍된 chunk가 이후 내비게이션 // 렌더를 방해하지 않는다 - // (import 호출 횟수는 단언하지 않는다 — 스펙 미규정, 계획서 §5) + // (import 호출 횟수는 단언하지 않는다 — 스펙 미규정) expect(await screen.findByText("A content")).toBeTruthy(); }); }); diff --git a/integrations/react/src/prepare.types.spec.tsx b/integrations/react/src/prepare.types.spec.tsx index 211df61df..edbd737e5 100644 --- a/integrations/react/src/prepare.types.spec.tsx +++ b/integrations/react/src/prepare.types.spec.tsx @@ -1,14 +1,18 @@ /** - * FEP-2357 — `prepare` 타입 안전성 (FEP-2357-TEST-PLAN.md §3의 G) + A1 + * FEP-2357 — `prepare` 타입 안전성 (G) + A1 (Linear FEP-2357) + * + * 잘못된 activity 이름·파라미터는 컴파일 타임에 차단되어야 한다 + * (`RegisteredActivityName` · `InferActivityParams` 제네릭 흐름). * * - G절은 `yarn workspace @stackflow/react typecheck`(tsconfig.test.json)로 * 검증된다. 모든 타입 단언은 절대 호출되지 않는 함수 본문 안에 배치한다 - * (@swc/jest는 타입을 검사하지 않으므로 런타임 실행을 막기 위함 — 계획서 §1). + * (@swc/jest는 타입을 검사하지 않으므로 런타임 실행을 막기 위함). * - `@ts-expect-error`는 "다음 줄에 컴파일 에러가 있어야 통과" 시맨틱이므로, * 규약이 깨지면 typecheck가 실패한다. * - Jest는 spec 파일에 최소 1개 테스트를 요구하므로 런타임 항목 A1을 이 - * 파일에 함께 둔다 (계획서 §1). - * - import는 public entry(`./index`)에서만 한다 (계획서 §0 import 경계). + * 파일에 함께 둔다. + * - import는 public entry(`./index`)에서만 한다 — 패키지명 import는 dist(빌드 + * 산출물)를 가리킨다. * * [TDD 상태 주의] `prepare`가 stackflow() 출력에 아직 없으므로, 이 파일은 * 구현 전까지 `output.prepare` 접근(TS2339)과 그에 따른 `@ts-expect-error` diff --git a/integrations/react/src/usePrepare.spec.tsx b/integrations/react/src/usePrepare.spec.tsx index 54a5058c4..77aecb569 100644 --- a/integrations/react/src/usePrepare.spec.tsx +++ b/integrations/react/src/usePrepare.spec.tsx @@ -1,11 +1,13 @@ /** - * FEP-2357 — `usePrepare` 래퍼 동등성 (FEP-2357-TEST-PLAN.md §3의 D) + * FEP-2357 — `usePrepare` 래퍼 동등성 (Linear FEP-2357) * - * usePrepare가 반환한 함수는 stackflow() 출력 `prepare`와 동일한 관찰 결과를 - * 보여야 한다 (스펙 §3 "동일 로직을 감싸는 얇은 래퍼"). + * `usePrepare`는 stackflow() 출력 `prepare`와 동일 로직을 감싸는 얇은 래퍼다. + * 반환 함수는 prepare.spec.tsx가 고정한 것과 동일한 관찰 결과(chunk + loader + * 발사, 미등록 activity reject)를 보여야 한다. * 이 절은 현행 동작 기준이므로 prepare 구현 이전에도 green이어야 한다. * - * - import는 public entry(`./index`)에서만 한다 (계획서 §0 import 경계). + * import는 public entry(`./index`)에서만 한다 — 패키지명 import는 dist(빌드 + * 산출물)를 가리킨다. */ import { defineConfig } from "@stackflow/config"; import { render } from "@testing-library/react"; @@ -26,7 +28,7 @@ declare module "@stackflow/config" { } } -/** 인라인 렌더러 플러그인 (계획서 §0 — plugin-renderer-basic은 순환 의존) */ +/** 인라인 렌더러 플러그인 — plugin-renderer-basic은 워크스페이스 순환 의존 */ const testRendererPlugin: StackflowReactPlugin = () => ({ key: "test-renderer", render({ stack }) { @@ -117,7 +119,7 @@ describe("usePrepare — D. 래퍼 동등성", () => { render(); // when: 미등록 이름으로 호출한다 (타입은 G1이 컴파일 타임에 차단하므로 - // 런타임 테스트는 as any로 우회한다 — 계획서 §2) + // 런타임 테스트는 as any로 우회한다) const p = capturedPrepare("Unknown" as any); // then: A8과 동일한 에러로 reject된다 From 813c5271b2f1926992672bad03e9be8e18495a6b Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Sun, 7 Jun 2026 21:28:47 +0900 Subject: [PATCH 08/15] test(react): drop plan-derived reference IDs, make comments self-contained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The A1-G4 numbering was an artifact of the deleted test plan — an index- less numbering scheme that rots on insertion. Test titles are the identifiers now; cross-references are descriptive; "unspecified in spec" phrasing becomes "not a contract" so comments presume no external doc. Issue provenance stays in commit messages (FEP-2357) per repo convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/react/src/prepare.spec.tsx | 104 +++++++++--------- integrations/react/src/prepare.types.spec.tsx | 32 +++--- integrations/react/src/usePrepare.spec.tsx | 20 ++-- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/integrations/react/src/prepare.spec.tsx b/integrations/react/src/prepare.spec.tsx index 26c0ff67c..324e6caca 100644 --- a/integrations/react/src/prepare.spec.tsx +++ b/integrations/react/src/prepare.spec.tsx @@ -1,22 +1,22 @@ /** - * FEP-2357 — `prepare` 런타임 규약 (Linear FEP-2357) + * `prepare` 런타임 규약 * * `stackflow()` 출력의 `prepare(activityName, activityParams?)`는 React 렌더링 * 트리 밖에서(렌더 이전 포함) activity component chunk와 data loader를 미리 * 발사한다. 이 파일이 고정하는 계약: * - * - A. params 생략 → chunk만, params 전달 → chunk + loader 발사 - * - B. 반환 Promise는 발사한 모든 작업 완료 시에만 resolve (중간 상태 미노출) - * - C. React 트리 부재 상태에서 완전 동작하고 이후 마운트를 방해하지 않음 - * - E. 모든 실패는 동기 throw가 아닌 원본 reason 그대로의 reject로 전달되고, - * chunk 실패는 재-prepare 시 재시도되며, prepare는 core store를 건드리지 - * 않는다(스택 상태·내비게이션 이벤트 불변) - * - F. loaderData 주입·lazy 렌더 등 기존 내비게이션 경로와의 책임 분리 + * - params 생략 → chunk만, params 전달 → chunk + loader 발사 + * - 반환 Promise는 발사한 모든 작업 완료 시에만 resolve (중간 상태 미노출) + * - React 트리 부재 상태에서 완전 동작하고 이후 마운트를 방해하지 않음 + * - 모든 실패는 동기 throw가 아닌 원본 reason 그대로의 reject로 전달되고, + * chunk 실패는 재-prepare 시 재시도되며, prepare는 core store를 건드리지 + * 않는다(스택 상태·내비게이션 이벤트 불변) + * - loaderData 주입·lazy 렌더 등 기존 내비게이션 경로와의 책임 분리 * - * 스펙이 구현 상세로 남긴 동작(loader 디듀프, chunk import 중복 발사, 부분 발사 - * 원자성/취소)은 어느 방향으로도 단언하지 않는다 — "스펙 미규정"으로 표기. + * loader 디듀프, chunk import 중복 발사, 부분 발사 원자성/취소는 계약이 아닌 + * 구현 상세로 남겨둔 동작이므로, 어느 방향으로도 단언하지 않는다. * - * usePrepare 래퍼 동등성(D)은 usePrepare.spec.tsx에, 타입 안전성(G)과 A1은 + * usePrepare 래퍼 동등성은 usePrepare.spec.tsx에, 타입 안전성은 * prepare.types.spec.tsx에 있다. * * import는 public entry(`./index`)에서만 한다 — `"@stackflow/react"` 패키지명 @@ -115,8 +115,8 @@ const testRendererPlugin: StackflowReactPlugin = () => ({ }); /** - * E4용 Suspense 래핑 변형 — lazy 컴포넌트가 pending chunk에서 suspend하므로 - * ``으로 감싼다. + * Suspense 래핑 변형 — pending chunk의 lazy 컴포넌트를 마운트하는 테스트는 + * 렌더가 suspend하므로 ``으로 감싼다. */ const suspenseTestRendererPlugin: StackflowReactPlugin = () => ({ key: "test-renderer", @@ -150,8 +150,8 @@ const baseComponents = { }; describe("prepare — stackflow() 출력", () => { - describe("A. 기본 규약 (렌더 없이 호출)", () => { - it("A2. params 생략 시 component chunk 로드만 발사하고 data loader는 호출하지 않는다", async () => { + describe("기본 규약 (렌더 없이 호출)", () => { + it("params 생략 시 component chunk 로드만 발사하고 data loader는 호출하지 않는다", async () => { // given: loader와 lazy 컴포넌트(import jest.fn)가 설정된 activity const loader = jest.fn(() => ({ data: "x" })); const importFn = jest.fn(() => @@ -174,7 +174,7 @@ describe("prepare — stackflow() 출력", () => { expect(loader).not.toHaveBeenCalled(); }); - it("A3. params 전달 시 chunk 로드와 data loader를 모두 발사한다", async () => { + it("params 전달 시 chunk 로드와 data loader를 모두 발사한다", async () => { // given: loader + lazy 컴포넌트(import jest.fn)인 activity const loader = jest.fn(() => ({ data: "x" })); const importFn = jest.fn(() => @@ -203,7 +203,7 @@ describe("prepare — stackflow() 출력", () => { expect(importFn).toHaveBeenCalled(); }); - it("A4. loader가 없는 activity에 params를 전달해도 에러 없이 resolve된다", async () => { + it("loader가 없는 activity에 params를 전달해도 에러 없이 resolve된다", async () => { // given: loader 없는 config + lazy 컴포넌트 const importFn = jest.fn(() => Promise.resolve({ default: () =>
A content
}), @@ -220,11 +220,11 @@ describe("prepare — stackflow() 출력", () => { // when: params를 전달해 호출한다 const p = prepare("PrepareActivityA", { id: "1" }); - // then: 반환 Promise가 에러 없이 resolve된다 (chunk 발사 검증은 A2의 규약) + // then: 반환 Promise가 에러 없이 resolve된다 (chunk 발사 검증은 위 테스트들의 규약) await expect(p).resolves.toBeUndefined(); }); - it("A5. lazy도 structured도 아닌 일반 컴포넌트는 아무 작업도 발사하지 않고 resolve된다", async () => { + it("lazy도 structured도 아닌 일반 컴포넌트는 아무 작업도 발사하지 않고 resolve된다", async () => { // given: 일반 함수 컴포넌트, loader 없는 activity const config = defineConfig({ activities: [{ name: "PrepareHomeActivity" }], @@ -242,7 +242,7 @@ describe("prepare — stackflow() 출력", () => { await expect(p).resolves.toBeUndefined(); }); - it("A6. structuredActivityComponent의 dynamic content는 content import를 발사한다", async () => { + it("structuredActivityComponent의 dynamic content는 content import를 발사한다", async () => { // given: content가 dynamic import 함수인 structured component const contentImportFn = jest.fn(() => Promise.resolve({ @@ -273,7 +273,7 @@ describe("prepare — stackflow() 출력", () => { expect(contentImportFn).toHaveBeenCalled(); }); - it("A7. structuredActivityComponent의 정적 content는 추가 로드 없이 resolve된다", async () => { + it("structuredActivityComponent의 정적 content는 추가 로드 없이 resolve된다", async () => { // given: content가 함수가 아닌 정적 값인 structured component const config = defineConfig({ activities: [{ name: "PrepareStructuredActivity" }], @@ -300,7 +300,7 @@ describe("prepare — stackflow() 출력", () => { await expect(p).resolves.toBeUndefined(); }); - it("A8. 미등록 activity 이름으로 호출하면 `Activity is not registered.` 에러로 reject된다", async () => { + it("미등록 activity 이름으로 호출하면 `Activity is not registered.` 에러로 reject된다", async () => { // given: 등록된 activity만 있는 stackflow 인스턴스 const config = defineConfig({ activities: [{ name: "PrepareHomeActivity" }], @@ -311,8 +311,8 @@ describe("prepare — stackflow() 출력", () => { components: { ...baseComponents }, }); - // when: 미등록 이름으로 호출한다 (타입은 G1이 컴파일 타임에 차단하므로 - // 런타임 테스트는 as any로 우회한다) + // when: 미등록 이름으로 호출한다 (타입은 prepare.types.spec.tsx가 컴파일 + // 타임에 차단하므로 런타임 테스트는 as any로 우회한다) // 동기 throw라면 이 줄에서 테스트가 실패하므로, 아래 단언이 // "throw가 아닌 reject" 계약을 함께 고정한다 const p = prepare("Unknown" as any); @@ -321,7 +321,7 @@ describe("prepare — stackflow() 출력", () => { await expect(p).rejects.toThrow("Activity Unknown is not registered."); }); - it('A9. 빈 객체 params도 "params 전달"로 취급되어 loader가 호출된다', async () => { + it('빈 객체 params도 "params 전달"로 취급되어 loader가 호출된다', async () => { // given: 파라미터가 없는({} 타입) activity + loader const loader = jest.fn(() => ({ data: "x" })); const config = defineConfig({ @@ -336,13 +336,13 @@ describe("prepare — stackflow() 출력", () => { // when: 빈 객체 params로 호출한다 await prepare("PrepareHomeActivity", {}); - // then: loader가 호출된다 (생략한 경우(A2)와 달리) + // then: loader가 호출된다 (params를 생략한 경우와 달리) expect(loader).toHaveBeenCalled(); }); }); - describe("B. 반환 Promise 의미 — 모든 작업 완료 시에만 resolve", () => { - it("B1. chunk 로드가 완료되기 전에는 resolve되지 않고, 완료되면 resolve된다", async () => { + describe("반환 Promise 의미 — 모든 작업 완료 시에만 resolve", () => { + it("chunk 로드가 완료되기 전에는 resolve되지 않고, 완료되면 resolve된다", async () => { // given: deferred로 제어되는 lazy import 함수 const chunkDeferred = createDeferred(); const importFn = jest.fn(() => chunkDeferred.promise); @@ -368,7 +368,7 @@ describe("prepare — stackflow() 출력", () => { await expect(p).resolves.toBeUndefined(); }); - it("B2. loader만 완료되고 chunk가 미완료인 동안에는 resolve되지 않는다 (중간 상태 미노출)", async () => { + it("loader만 완료되고 chunk가 미완료인 동안에는 resolve되지 않는다 (중간 상태 미노출)", async () => { // given: loader와 lazy import 각각을 제어하는 deferred 2개 const loaderDeferred = createDeferred<{ data: string }>(); const chunkDeferred = createDeferred(); @@ -397,8 +397,8 @@ describe("prepare — stackflow() 출력", () => { await expect(p).resolves.toBeUndefined(); }); - it("B3. chunk만 완료되고 loader가 미완료인 동안에는 resolve되지 않는다 (B2의 대칭)", async () => { - // given: B2와 동일한 픽스처 + it("chunk만 완료되고 loader가 미완료인 동안에는 resolve되지 않는다", async () => { + // given: loader와 lazy import 각각을 제어하는 deferred 2개 (위 테스트의 대칭) const loaderDeferred = createDeferred<{ data: string }>(); const chunkDeferred = createDeferred(); const loader = jest.fn(() => loaderDeferred.promise); @@ -427,8 +427,8 @@ describe("prepare — stackflow() 출력", () => { }); }); - describe("C. React 밖 / 렌더 전 호출 가능성", () => { - it("C1. 렌더 없이(React 트리 부재) prepare가 완전한 동작을 한다", async () => { + describe("React 밖 / 렌더 전 호출 가능성", () => { + it(" 렌더 없이(React 트리 부재) prepare가 완전한 동작을 한다", async () => { // given: stackflow() 호출 직후, 어떤 컴포넌트도 렌더하지 않은 상태 // (loader + lazy activity) const loader = jest.fn(() => ({ data: "x" })); @@ -452,7 +452,7 @@ describe("prepare — stackflow() 출력", () => { expect(importFn).toHaveBeenCalled(); }); - it("C2. 렌더 전 prepare 호출이 이후 마운트를 방해하지 않는다", async () => { + it("렌더 전 prepare 호출이 이후 마운트를 방해하지 않는다", async () => { // given: lazy activity에 대한 prepare 완료, initialActivity는 일반 컴포넌트 function HomeActivity() { return
home
; @@ -487,8 +487,8 @@ describe("prepare — stackflow() 출력", () => { }); }); - describe("E. 동시성 · 경쟁 상태 · 실패", () => { - it("E1. 동일 activity에 대한 동시 중복 prepare — 두 Promise 모두 작업 완료 후 각각 resolve된다", async () => { + describe("동시성 · 경쟁 상태 · 실패", () => { + it("동일 activity에 대한 동시 중복 prepare — 두 Promise 모두 작업 완료 후 각각 resolve된다", async () => { // given: deferred chunk를 가진 lazy activity. import 함수는 호출마다 // 동일한 deferred.promise를 반환한다 — 구현이 디듀프하든 안 하든 // 테스트 결과가 같도록(디듀프-불가지 픽스처) @@ -515,12 +515,12 @@ describe("prepare — stackflow() 출력", () => { chunkDeferred.resolve({ default: () =>
A content
}); // then: 두 Promise 모두 resolve된다 - // (import 함수/loader의 호출 횟수는 단언하지 않는다 — 스펙 미규정) + // (import 함수/loader의 호출 횟수는 계약이 아니므로 단언하지 않는다) await expect(p1).resolves.toBeUndefined(); await expect(p2).resolves.toBeUndefined(); }); - it("E2. 서로 다른 activity의 동시 prepare는 서로 간섭하지 않는다", async () => { + it("서로 다른 activity의 동시 prepare는 서로 간섭하지 않는다", async () => { // given: 각각 deferred chunk를 가진 lazy activity 2개 const chunkADeferred = createDeferred(); const chunkBDeferred = createDeferred(); @@ -558,7 +558,7 @@ describe("prepare — stackflow() 출력", () => { await expect(pA).resolves.toBeUndefined(); }); - it("E3. prepare 진행 중 같은 activity로 push가 발생해도 push는 정상 완료된다", async () => { + it("prepare 진행 중 같은 activity로 push가 발생해도 push는 정상 완료된다", async () => { // given: 렌더(initial: 일반 Home), deferred chunk의 lazy activity, // spy 플러그인(getStack), 미완료 prepare 발사 let getStack!: () => CoreStack; @@ -611,7 +611,7 @@ describe("prepare — stackflow() 출력", () => { expect(activities[activities.length - 1].enteredBy.name).toBe("Pushed"); }); - it("E4. prepare 진행 중 마운트(부트스트랩 시나리오)도 정상 동작한다", async () => { + it("prepare 진행 중 마운트(부트스트랩 시나리오)도 정상 동작한다", async () => { // given: deferred chunk의 lazy activity(loader 없음)가 initialActivity, // Suspense 래핑 인라인 렌더러, prepare 발사 직후(미완료) const chunkDeferred = createDeferred(); @@ -640,7 +640,7 @@ describe("prepare — stackflow() 출력", () => { expect(await screen.findByText("A content")).toBeTruthy(); }); - it("E5. loader가 동기 throw하면 반환 Promise는 해당 에러로 reject된다", async () => { + it("loader가 동기 throw하면 반환 Promise는 해당 에러로 reject된다", async () => { // given: 동기 throw하는 loader인 activity (+ lazy 컴포넌트) const err = new Error("loader sync throw"); const loader = jest.fn(() => { @@ -662,11 +662,11 @@ describe("prepare — stackflow() 출력", () => { const p = prepare("PrepareActivityA", { id: "1" }); // then: 해당 에러로 reject된다 - // (chunk 발사 여부는 단언하지 않는다 — 부분 발사 원자성은 스펙 미규정) + // (부분 발사 원자성은 계약이 아니므로 chunk 발사 여부는 단언하지 않는다) await expect(p).rejects.toBe(err); }); - it("E6. loader가 비동기 reject하면 반환 Promise는 해당 reason으로 reject된다", async () => { + it("loader가 비동기 reject하면 반환 Promise는 해당 reason으로 reject된다", async () => { // given: reject하는 loader인 activity const err = new Error("loader async reject"); const loader = jest.fn(() => Promise.reject(err)); @@ -686,7 +686,7 @@ describe("prepare — stackflow() 출력", () => { await expect(p).rejects.toBe(err); }); - it("E7. chunk 로드가 reject하면 반환 Promise는 해당 reason으로 reject된다", async () => { + it("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)); @@ -706,7 +706,7 @@ describe("prepare — stackflow() 출력", () => { await expect(p).rejects.toBe(err); }); - it("E8. chunk 로드 실패 후 같은 activity를 다시 prepare하면 로드를 재시도한다", async () => { + it("chunk 로드 실패 후 같은 activity를 다시 prepare하면 로드를 재시도한다", async () => { // given: 첫 호출은 reject, 두 번째 호출은 resolve하는 lazy import const err = new Error("chunk load failed"); const importFn = jest @@ -735,7 +735,7 @@ describe("prepare — stackflow() 출력", () => { expect(importFn).toHaveBeenCalledTimes(2); }); - it("E9. prepare 실패가 이후 내비게이션과 다른 prepare를 오염시키지 않는다 (오류 격리 invariant)", async () => { + it("prepare 실패가 이후 내비게이션과 다른 prepare를 오염시키지 않는다 (오류 격리 invariant)", async () => { // given: loader가 reject하는 A, 정상 lazy + loader의 B, // 렌더 + spy 플러그인 let getStack!: () => CoreStack; @@ -794,7 +794,7 @@ describe("prepare — stackflow() 출력", () => { expect(activities[activities.length - 1].name).toBe("PrepareActivityB"); }); - it("E10. prepare는 스택 상태를 변경하지 않으며 내비게이션 이벤트를 발생시키지 않는다", async () => { + it("prepare는 스택 상태를 변경하지 않으며 내비게이션 이벤트를 발생시키지 않는다", async () => { // given: 렌더, spy 플러그인(getStack + onChanged/onBeforePush/onPushed // 기록), loader + lazy의 activity let getStack!: () => CoreStack; @@ -856,12 +856,12 @@ describe("prepare — stackflow() 출력", () => { }); }); - describe("F. loaderPlugin과의 책임 분리", () => { + describe("loaderPlugin과의 책임 분리", () => { // 주의: 이 절은 호출 횟수를 단언하지 않는다. loader 디듀프·chunk 중복 발사 - // 여부는 스펙 미규정이며, 여기서는 "prepare가 기존 내비게이션 경로 + // 여부는 계약이 아니며, 여기서는 "prepare가 기존 내비게이션 경로 // (loaderData 주입·lazy 렌더)를 방해하지 않는다"는 책임 분리만 검증한다. - it("F1. prepare 후 push해도 loaderData 주입은 loaderPlugin 경로로 정상 동작한다", async () => { + it("prepare 후 push해도 loaderData 주입은 loaderPlugin 경로로 정상 동작한다", async () => { // given: 동기 데이터를 반환하는 loader의 activity, // 해당 컴포넌트는 useLoaderData() 값을 렌더. 렌더(initial: Home) function HomeActivity() { @@ -903,7 +903,7 @@ describe("prepare — stackflow() 출력", () => { expect(await screen.findByText("loaded")).toBeTruthy(); }); - it("F2. prepare 완료 후 push하면 lazy activity가 정상 렌더된다", async () => { + it("prepare 완료 후 push하면 lazy activity가 정상 렌더된다", async () => { // given: resolve되는 lazy의 activity, 렌더(initial: Home) function HomeActivity() { return
home
; @@ -939,7 +939,7 @@ describe("prepare — stackflow() 출력", () => { // then: activity의 콘텐츠가 렌더된다 — 워밍된 chunk가 이후 내비게이션 // 렌더를 방해하지 않는다 - // (import 호출 횟수는 단언하지 않는다 — 스펙 미규정) + // (import 호출 횟수는 계약이 아니므로 단언하지 않는다) expect(await screen.findByText("A content")).toBeTruthy(); }); }); diff --git a/integrations/react/src/prepare.types.spec.tsx b/integrations/react/src/prepare.types.spec.tsx index edbd737e5..1ab8482d8 100644 --- a/integrations/react/src/prepare.types.spec.tsx +++ b/integrations/react/src/prepare.types.spec.tsx @@ -1,16 +1,16 @@ /** - * FEP-2357 — `prepare` 타입 안전성 (G) + A1 (Linear FEP-2357) + * `prepare` 타입 안전성 * * 잘못된 activity 이름·파라미터는 컴파일 타임에 차단되어야 한다 * (`RegisteredActivityName` · `InferActivityParams` 제네릭 흐름). * - * - G절은 `yarn workspace @stackflow/react typecheck`(tsconfig.test.json)로 - * 검증된다. 모든 타입 단언은 절대 호출되지 않는 함수 본문 안에 배치한다 + * - 타입 단언은 `yarn workspace @stackflow/react typecheck`(tsconfig.test.json)로 + * 검증된다. 모두 절대 호출되지 않는 함수 본문 안에 배치한다 * (@swc/jest는 타입을 검사하지 않으므로 런타임 실행을 막기 위함). * - `@ts-expect-error`는 "다음 줄에 컴파일 에러가 있어야 통과" 시맨틱이므로, * 규약이 깨지면 typecheck가 실패한다. - * - Jest는 spec 파일에 최소 1개 테스트를 요구하므로 런타임 항목 A1을 이 - * 파일에 함께 둔다. + * - Jest는 spec 파일에 최소 1개 테스트를 요구하므로 런타임 항목(출력 형태 + * 확인)을 이 파일에 함께 둔다. * - import는 public entry(`./index`)에서만 한다 — 패키지명 import는 dist(빌드 * 산출물)를 가리킨다. * @@ -58,8 +58,8 @@ const output = stackflow({ components: baseComponents, }); -describe("prepare — A. 기본 규약 (출력 형태)", () => { - it("A1. stackflow() 출력에 prepare 함수가 포함된다", () => { +describe("prepare — 출력 형태", () => { + it("stackflow() 출력에 prepare 함수가 포함된다", () => { // given: defineConfig + components로 stackflow()를 호출한다 (모듈 상단 픽스처) // when: 반환 객체를 확인한다 // then: prepare가 함수다 @@ -67,35 +67,35 @@ describe("prepare — A. 기본 규약 (출력 형태)", () => { }); }); -// --- G. 타입 안전성 --- +// --- 타입 안전성 --- // 아래 함수들은 typecheck 전용이며 절대 호출되지 않는다. -/** G1. 미등록 activity 이름은 컴파일 에러다 */ -function _typecheckG1() { +/** 미등록 activity 이름은 컴파일 에러다 */ +function _typecheckUnregisteredActivityName() { // @ts-expect-error Register에 증강되지 않은 이름은 거부된다 output.prepare("NotRegistered"); } -/** G2. 잘못된 params 타입은 컴파일 에러다 */ -function _typecheckG2() { +/** 잘못된 params 타입은 컴파일 에러다 */ +function _typecheckInvalidParams() { // @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() { +/** params는 생략 가능하고 반환 타입은 Promise다 */ +function _typecheckOptionalParamsAndReturnType() { const r1: Promise = output.prepare("PrepareActivityA"); const r2: Promise = output.prepare("PrepareActivityA", { id: "1" }); return [r1, r2]; } /** - * G4. stackflow() 출력 prepare와 usePrepare 반환값은 모두 Prepare 타입과 + * stackflow() 출력 prepare와 usePrepare 반환값은 모두 Prepare 타입과 * 상호 할당 가능하다 — 두 진입점이 동일한 공개 시그니처를 공유한다 */ -function _typecheckG4(up: ReturnType) { +function _typecheckPrepareTypeEquivalence(up: ReturnType) { // 정방향: 두 진입점 → Prepare const a: Prepare = output.prepare; const b: Prepare = up; diff --git a/integrations/react/src/usePrepare.spec.tsx b/integrations/react/src/usePrepare.spec.tsx index 77aecb569..c46670a11 100644 --- a/integrations/react/src/usePrepare.spec.tsx +++ b/integrations/react/src/usePrepare.spec.tsx @@ -1,10 +1,10 @@ /** - * FEP-2357 — `usePrepare` 래퍼 동등성 (Linear FEP-2357) + * `usePrepare` 래퍼 동등성 * * `usePrepare`는 stackflow() 출력 `prepare`와 동일 로직을 감싸는 얇은 래퍼다. * 반환 함수는 prepare.spec.tsx가 고정한 것과 동일한 관찰 결과(chunk + loader * 발사, 미등록 activity reject)를 보여야 한다. - * 이 절은 현행 동작 기준이므로 prepare 구현 이전에도 green이어야 한다. + * 이 파일은 현행 동작 기준이므로 prepare 구현 이전에도 green이어야 한다. * * import는 public entry(`./index`)에서만 한다 — 패키지명 import는 dist(빌드 * 산출물)를 가리킨다. @@ -56,8 +56,8 @@ const baseComponents = { PrepareStructuredActivity: PlainActivity, }; -describe("usePrepare — D. 래퍼 동등성", () => { - it("D1. usePrepare가 반환한 함수도 chunk + data를 동일하게 발사한다", async () => { +describe("usePrepare — 래퍼 동등성", () => { + it("usePrepare가 반환한 함수도 chunk + data를 동일하게 발사한다", async () => { // given: 렌더 — 초기 activity 내부에서 usePrepare() 반환값을 // 외부 변수로 캡처. 별도의 lazy + loader activity B. let capturedPrepare!: Prepare; @@ -92,15 +92,15 @@ describe("usePrepare — D. 래퍼 동등성", () => { await capturedPrepare("PrepareActivityB", { id: "1" }); // then: loader가 params 인자로 호출되고, import 함수가 호출된다 - // — A3과 동일한 관찰 결과 + // — stackflow() 출력 prepare와 동일한 관찰 결과 expect(loader).toHaveBeenCalledWith( expect.objectContaining({ params: { id: "1" } }), ); expect(importFn).toHaveBeenCalled(); }); - it("D2. usePrepare가 반환한 함수도 미등록 activity에 동일 에러로 reject된다", async () => { - // given: D1과 동일하게 캡처한 함수 + it("usePrepare가 반환한 함수도 미등록 activity에 동일 에러로 reject된다", async () => { + // given: 초기 activity 내부에서 usePrepare() 반환값을 외부 변수로 캡처 let capturedPrepare!: Prepare; function HomeActivity() { capturedPrepare = usePrepare(); @@ -118,11 +118,11 @@ describe("usePrepare — D. 래퍼 동등성", () => { }); render(); - // when: 미등록 이름으로 호출한다 (타입은 G1이 컴파일 타임에 차단하므로 - // 런타임 테스트는 as any로 우회한다) + // when: 미등록 이름으로 호출한다 (타입은 prepare.types.spec.tsx가 컴파일 + // 타임에 차단하므로 런타임 테스트는 as any로 우회한다) const p = capturedPrepare("Unknown" as any); - // then: A8과 동일한 에러로 reject된다 + // then: stackflow() 출력 prepare와 동일한 에러로 reject된다 await expect(p).rejects.toThrow("Activity Unknown is not registered."); }); }); From 73a85aea772079c8e968c38e96e6defeb92dbf2e Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Sun, 7 Jun 2026 21:34:19 +0900 Subject: [PATCH 09/15] test(react): drop prepare type-safety spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compile-time guarantees follow from reusing the existing Prepare type (RegisteredActivityName / InferActivityParams generics already exercised across the codebase), and the output-shape runtime check was redundant — every runtime spec destructures and calls prepare anyway. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/react/src/prepare.spec.tsx | 7 +- integrations/react/src/prepare.types.spec.tsx | 106 ------------------ integrations/react/src/usePrepare.spec.tsx | 4 +- 3 files changed, 5 insertions(+), 112 deletions(-) delete mode 100644 integrations/react/src/prepare.types.spec.tsx diff --git a/integrations/react/src/prepare.spec.tsx b/integrations/react/src/prepare.spec.tsx index 324e6caca..3fba48bfd 100644 --- a/integrations/react/src/prepare.spec.tsx +++ b/integrations/react/src/prepare.spec.tsx @@ -16,8 +16,7 @@ * loader 디듀프, chunk import 중복 발사, 부분 발사 원자성/취소는 계약이 아닌 * 구현 상세로 남겨둔 동작이므로, 어느 방향으로도 단언하지 않는다. * - * usePrepare 래퍼 동등성은 usePrepare.spec.tsx에, 타입 안전성은 - * prepare.types.spec.tsx에 있다. + * usePrepare 래퍼 동등성은 usePrepare.spec.tsx에 있다. * * import는 public entry(`./index`)에서만 한다 — `"@stackflow/react"` 패키지명 * import는 dist(빌드 산출물)를 가리키므로 src 변경 대신 stale artifact를 @@ -311,8 +310,8 @@ describe("prepare — stackflow() 출력", () => { components: { ...baseComponents }, }); - // when: 미등록 이름으로 호출한다 (타입은 prepare.types.spec.tsx가 컴파일 - // 타임에 차단하므로 런타임 테스트는 as any로 우회한다) + // when: 미등록 이름으로 호출한다 (미등록 이름은 타입이 거부하므로 + // 런타임 테스트는 as any로 우회한다) // 동기 throw라면 이 줄에서 테스트가 실패하므로, 아래 단언이 // "throw가 아닌 reject" 계약을 함께 고정한다 const p = prepare("Unknown" as any); diff --git a/integrations/react/src/prepare.types.spec.tsx b/integrations/react/src/prepare.types.spec.tsx deleted file mode 100644 index 1ab8482d8..000000000 --- a/integrations/react/src/prepare.types.spec.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * `prepare` 타입 안전성 - * - * 잘못된 activity 이름·파라미터는 컴파일 타임에 차단되어야 한다 - * (`RegisteredActivityName` · `InferActivityParams` 제네릭 흐름). - * - * - 타입 단언은 `yarn workspace @stackflow/react typecheck`(tsconfig.test.json)로 - * 검증된다. 모두 절대 호출되지 않는 함수 본문 안에 배치한다 - * (@swc/jest는 타입을 검사하지 않으므로 런타임 실행을 막기 위함). - * - `@ts-expect-error`는 "다음 줄에 컴파일 에러가 있어야 통과" 시맨틱이므로, - * 규약이 깨지면 typecheck가 실패한다. - * - Jest는 spec 파일에 최소 1개 테스트를 요구하므로 런타임 항목(출력 형태 - * 확인)을 이 파일에 함께 둔다. - * - import는 public entry(`./index`)에서만 한다 — 패키지명 import는 dist(빌드 - * 산출물)를 가리킨다. - * - * [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 — 출력 형태", () => { - it("stackflow() 출력에 prepare 함수가 포함된다", () => { - // given: defineConfig + components로 stackflow()를 호출한다 (모듈 상단 픽스처) - // when: 반환 객체를 확인한다 - // then: prepare가 함수다 - expect(typeof output.prepare).toBe("function"); - }); -}); - -// --- 타입 안전성 --- -// 아래 함수들은 typecheck 전용이며 절대 호출되지 않는다. - -/** 미등록 activity 이름은 컴파일 에러다 */ -function _typecheckUnregisteredActivityName() { - // @ts-expect-error Register에 증강되지 않은 이름은 거부된다 - output.prepare("NotRegistered"); -} - -/** 잘못된 params 타입은 컴파일 에러다 */ -function _typecheckInvalidParams() { - // @ts-expect-error params 값 타입 불일치(string 자리에 number)는 거부된다 - output.prepare("PrepareActivityA", { id: 123 }); - // @ts-expect-error 정의되지 않은 params 키는 거부된다 - output.prepare("PrepareActivityA", { wrong: "x" }); -} - -/** params는 생략 가능하고 반환 타입은 Promise다 */ -function _typecheckOptionalParamsAndReturnType() { - const r1: Promise = output.prepare("PrepareActivityA"); - const r2: Promise = output.prepare("PrepareActivityA", { id: "1" }); - return [r1, r2]; -} - -/** - * stackflow() 출력 prepare와 usePrepare 반환값은 모두 Prepare 타입과 - * 상호 할당 가능하다 — 두 진입점이 동일한 공개 시그니처를 공유한다 - */ -function _typecheckPrepareTypeEquivalence(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]; -} diff --git a/integrations/react/src/usePrepare.spec.tsx b/integrations/react/src/usePrepare.spec.tsx index c46670a11..442c22492 100644 --- a/integrations/react/src/usePrepare.spec.tsx +++ b/integrations/react/src/usePrepare.spec.tsx @@ -118,8 +118,8 @@ describe("usePrepare — 래퍼 동등성", () => { }); render(); - // when: 미등록 이름으로 호출한다 (타입은 prepare.types.spec.tsx가 컴파일 - // 타임에 차단하므로 런타임 테스트는 as any로 우회한다) + // when: 미등록 이름으로 호출한다 (미등록 이름은 타입이 거부하므로 + // 런타임 테스트는 as any로 우회한다) const p = capturedPrepare("Unknown" as any); // then: stackflow() 출력 prepare와 동일한 에러로 reject된다 From 8f6e972bac3bb9cbae63539ce40099b8766448bb Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Sun, 7 Jun 2026 21:37:37 +0900 Subject: [PATCH 10/15] feat(react): declare prepare in StackflowOutput with unimplemented stub Reuses the public Prepare type so the compile-time contract (activity name / params inference) is fixed ahead of implementation and the spec files typecheck green. The stub throws explicitly, keeping the runtime specs red with a uniform, unambiguous reason until a worker fills in the implementation. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/react/src/stackflow.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/integrations/react/src/stackflow.tsx b/integrations/react/src/stackflow.tsx index 5cbab9846..66d6d8c1d 100644 --- a/integrations/react/src/stackflow.tsx +++ b/integrations/react/src/stackflow.tsx @@ -27,6 +27,7 @@ import { makeActions } from "./makeActions"; import { makeStepActions } from "./makeStepActions"; import type { StackComponentType } from "./StackComponentType"; import type { StepActions } from "./StepActions"; +import type { Prepare } from "./usePrepare"; export type StackflowPluginsEntry = | StackflowReactPlugin @@ -47,6 +48,7 @@ export type StackflowOutput = { Stack: StackComponentType; actions: Actions; stepActions: StepActions; + prepare: Prepare; }; export function stackflow< @@ -201,5 +203,10 @@ export function stackflow< Stack, actions: makeActions(() => getCoreStore()?.actions), stepActions: makeStepActions(() => getCoreStore()?.actions), + prepare: () => { + // TODO: Implement by reusing `usePrepare`'s logic outside the React + // context. The contract is pinned by prepare.spec.tsx. + throw new Error("stackflow().prepare is not implemented yet."); + }, }; } From 4aa375c2f09d4614bac95a3d3658f040d6b539a9 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Sun, 7 Jun 2026 21:41:13 +0900 Subject: [PATCH 11/15] refactor(react): move Prepare type to a dedicated module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stackflow() owns the prepare contract; usePrepare is the wrapper. The type now lives in Prepare.ts (same pattern as Actions/StepActions) and both sides reference it, instead of the core importing it from the hook. Also drop the implementation-guidance comment from the stub — the specs alone define what to build. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/react/src/Prepare.ts | 9 +++++++++ integrations/react/src/index.ts | 1 + integrations/react/src/stackflow.tsx | 4 +--- integrations/react/src/usePrepare.ts | 6 +----- 4 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 integrations/react/src/Prepare.ts diff --git a/integrations/react/src/Prepare.ts b/integrations/react/src/Prepare.ts new file mode 100644 index 000000000..a643e8d20 --- /dev/null +++ b/integrations/react/src/Prepare.ts @@ -0,0 +1,9 @@ +import type { + InferActivityParams, + RegisteredActivityName, +} from "@stackflow/config"; + +export type Prepare = ( + activityName: K, + activityParams?: InferActivityParams, +) => Promise; diff --git a/integrations/react/src/index.ts b/integrations/react/src/index.ts index 08f7d178a..d14ac1cf5 100644 --- a/integrations/react/src/index.ts +++ b/integrations/react/src/index.ts @@ -7,6 +7,7 @@ export * from "./Actions"; export * from "./ActivityComponentType"; export * from "./lazy"; export * from "./loader/useLoaderData"; +export * from "./Prepare"; export * from "./StackComponentType"; export * from "./StaticActivityComponentType"; export * from "./StepActions"; diff --git a/integrations/react/src/stackflow.tsx b/integrations/react/src/stackflow.tsx index 66d6d8c1d..606d535e8 100644 --- a/integrations/react/src/stackflow.tsx +++ b/integrations/react/src/stackflow.tsx @@ -27,7 +27,7 @@ import { makeActions } from "./makeActions"; import { makeStepActions } from "./makeStepActions"; import type { StackComponentType } from "./StackComponentType"; import type { StepActions } from "./StepActions"; -import type { Prepare } from "./usePrepare"; +import type { Prepare } from "./Prepare"; export type StackflowPluginsEntry = | StackflowReactPlugin @@ -204,8 +204,6 @@ export function stackflow< actions: makeActions(() => getCoreStore()?.actions), stepActions: makeStepActions(() => getCoreStore()?.actions), prepare: () => { - // TODO: Implement by reusing `usePrepare`'s logic outside the React - // context. The contract is pinned by prepare.spec.tsx. throw new Error("stackflow().prepare is not implemented yet."); }, }; diff --git a/integrations/react/src/usePrepare.ts b/integrations/react/src/usePrepare.ts index 379c97df6..9f734f075 100644 --- a/integrations/react/src/usePrepare.ts +++ b/integrations/react/src/usePrepare.ts @@ -9,13 +9,9 @@ import { isStructuredActivityComponent, } from "./StructuredActivityComponentType"; import { useDataLoader } from "./loader"; +import type { Prepare } from "./Prepare"; import { useConfig } from "./useConfig"; -export type Prepare = ( - activityName: K, - activityParams?: InferActivityParams, -) => Promise; - export function usePrepare(): Prepare { const config = useConfig(); const loadData = useDataLoader(); From 2f7f20652f989b82ba949945f53d3fa1734c4241 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Sun, 7 Jun 2026 21:53:40 +0900 Subject: [PATCH 12/15] test(react): state fire/consume split and pre-init usability as the contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "separation of responsibility" header line now says what it means: prepare only fires work; loaderData injection and lazy rendering stay with the existing navigation path (prepare → push behaves like plain push). The core-store-non-touching line is rephrased as the observable capability it grants: prepare is usable before the core store initializes. Co-Authored-By: Claude Opus 4.8 (1M context) --- integrations/react/src/prepare.spec.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/integrations/react/src/prepare.spec.tsx b/integrations/react/src/prepare.spec.tsx index 3fba48bfd..6874a79e2 100644 --- a/integrations/react/src/prepare.spec.tsx +++ b/integrations/react/src/prepare.spec.tsx @@ -7,11 +7,13 @@ * * - params 생략 → chunk만, params 전달 → chunk + loader 발사 * - 반환 Promise는 발사한 모든 작업 완료 시에만 resolve (중간 상태 미노출) - * - React 트리 부재 상태에서 완전 동작하고 이후 마운트를 방해하지 않음 + * - stackflow() core store 초기화 이전(React 트리 부재 상태)에도 prepare를 + * 사용할 수 있고, 이후 마운트를 방해하지 않음 * - 모든 실패는 동기 throw가 아닌 원본 reason 그대로의 reject로 전달되고, - * chunk 실패는 재-prepare 시 재시도되며, prepare는 core store를 건드리지 - * 않는다(스택 상태·내비게이션 이벤트 불변) - * - loaderData 주입·lazy 렌더 등 기존 내비게이션 경로와의 책임 분리 + * chunk 실패는 재-prepare 시 재시도된다 + * - prepare는 발사만 담당하며, 결과 소비(loaderData 주입·lazy 렌더)는 기존 + * 내비게이션 경로가 그대로 담당한다 — prepare → push가 일반 push와 동일하게 + * 동작 * * loader 디듀프, chunk import 중복 발사, 부분 발사 원자성/취소는 계약이 아닌 * 구현 상세로 남겨둔 동작이므로, 어느 방향으로도 단언하지 않는다. @@ -855,10 +857,11 @@ describe("prepare — stackflow() 출력", () => { }); }); - describe("loaderPlugin과의 책임 분리", () => { + describe("발사/소비 분리 — prepare 이후 push는 일반 push와 동일하게 동작", () => { // 주의: 이 절은 호출 횟수를 단언하지 않는다. loader 디듀프·chunk 중복 발사 - // 여부는 계약이 아니며, 여기서는 "prepare가 기존 내비게이션 경로 - // (loaderData 주입·lazy 렌더)를 방해하지 않는다"는 책임 분리만 검증한다. + // 여부는 계약이 아니며, 여기서는 prepare의 책임이 발사에서 끝나고 결과 + // 소비(loaderData 주입은 loaderPlugin, chunk 렌더는 lazy 경로)는 기존 + // 내비게이션 경로가 그대로 담당한다는 것만 검증한다. it("prepare 후 push해도 loaderData 주입은 loaderPlugin 경로로 정상 동작한다", async () => { // given: 동기 데이터를 반환하는 loader의 activity, From cfae420fead2a4bbd03f4580830f70b388aa4ab4 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 8 Jun 2026 11:53:36 +0900 Subject: [PATCH 13/15] feat(react): implement prepare outside React context (FEP-2357) Extract the usePrepare body into a context-free `makePrepare` factory that takes the three stackflow() inputs it actually depends on (config, loadData, activityComponentMap) and returns the `Prepare` function. Wire it as the `prepare` field of the stackflow() output so chunk/data preloading can run before the React tree mounts, and refactor `usePrepare` into a thin wrapper over the same implementation so in-tree callers are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/fep-2357-prepare-outside-react.md | 14 ++++ integrations/react/src/makePrepare.ts | 71 ++++++++++++++++++++ integrations/react/src/stackflow.tsx | 9 ++- integrations/react/src/usePrepare.ts | 56 +++------------ 4 files changed, 102 insertions(+), 48 deletions(-) create mode 100644 .changeset/fep-2357-prepare-outside-react.md create mode 100644 integrations/react/src/makePrepare.ts diff --git a/.changeset/fep-2357-prepare-outside-react.md b/.changeset/fep-2357-prepare-outside-react.md new file mode 100644 index 000000000..e855cde12 --- /dev/null +++ b/.changeset/fep-2357-prepare-outside-react.md @@ -0,0 +1,14 @@ +--- +"@stackflow/react": minor +--- + +Expose `prepare` on the `stackflow()` output to preload an activity's component chunk and data loader from outside the React render tree (e.g. at app bootstrap, before the first render), without depending on React Context. + +```ts +const { Stack, actions, stepActions, prepare } = stackflow({ config, components, plugins }); + +prepare("Article", { articleId: "123" }); // warm chunk + fire data loader +prepare("Article"); // warm chunk only +``` + +The signature matches the existing `usePrepare` hook (omitting params warms the chunk only; passing params also fires the loader), and `usePrepare` is now a thin wrapper over the same implementation, so in-tree callers are unchanged. Failures are delivered as a rejection of the returned promise rather than a synchronous throw. diff --git a/integrations/react/src/makePrepare.ts b/integrations/react/src/makePrepare.ts new file mode 100644 index 000000000..9bf8a23c4 --- /dev/null +++ b/integrations/react/src/makePrepare.ts @@ -0,0 +1,71 @@ +import type { + ActivityDefinition, + Config, + InferActivityParams, + RegisteredActivityName, +} from "@stackflow/config"; +import type { ActivityComponentType } from "./BaseActivityComponentType"; +import type { Prepare } from "./Prepare"; +import { + getContentComponent, + isStructuredActivityComponent, +} from "./StructuredActivityComponentType"; + +export type MakePrepareInput = { + config: Config>; + loadData: (activityName: string, activityParams: {}) => unknown; + activityComponentMap: { + [activityName in RegisteredActivityName]: ActivityComponentType; + }; +}; + +/** + * `prepare` 구현 코어. + * + * React Context에 의존하지 않고, `stackflow()` 입력에서 직접 파생되는 세 가지 + * (`config`, `loadData`, `activityComponentMap`)만 받아 `prepare` 함수를 만든다. + * 따라서 React 렌더링 트리 밖(모듈 평가 시점 포함)에서도 호출할 수 있다. + * + * `stackflow()` 출력의 `prepare`와 `usePrepare` 래퍼가 이 단일 구현을 공유한다. + */ +export function makePrepare({ + config, + loadData, + activityComponentMap, +}: MakePrepareInput): Prepare { + return async function prepare( + activityName: K, + activityParams?: InferActivityParams, + ) { + const activityConfig = config.activities.find( + ({ name }) => name === activityName, + ); + const prefetchTasks: Promise[] = []; + + if (!activityConfig) + throw new Error(`Activity ${activityName} is not registered.`); + + if (activityParams && activityConfig.loader) { + prefetchTasks.push( + Promise.resolve(loadData(activityName, activityParams)), + ); + } + + if ("_load" in activityComponentMap[activityName]) { + prefetchTasks.push( + Promise.resolve(activityComponentMap[activityName]._load?.()), + ); + } + + if ( + isStructuredActivityComponent(activityComponentMap[activityName]) && + typeof activityComponentMap[activityName].content === "function" + ) { + prefetchTasks.push( + getContentComponent(activityComponentMap[activityName]).preload(), + ); + } + + await Promise.all(prefetchTasks); + }; +} diff --git a/integrations/react/src/stackflow.tsx b/integrations/react/src/stackflow.tsx index 606d535e8..61332554e 100644 --- a/integrations/react/src/stackflow.tsx +++ b/integrations/react/src/stackflow.tsx @@ -24,6 +24,7 @@ import type { Actions } from "./Actions"; import { ConfigProvider } from "./ConfigProvider"; import { DataLoaderProvider, loaderPlugin } from "./loader"; import { makeActions } from "./makeActions"; +import { makePrepare } from "./makePrepare"; import { makeStepActions } from "./makeStepActions"; import type { StackComponentType } from "./StackComponentType"; import type { StepActions } from "./StepActions"; @@ -203,8 +204,10 @@ export function stackflow< Stack, actions: makeActions(() => getCoreStore()?.actions), stepActions: makeStepActions(() => getCoreStore()?.actions), - prepare: () => { - throw new Error("stackflow().prepare is not implemented yet."); - }, + prepare: makePrepare({ + config: input.config, + loadData, + activityComponentMap: input.components, + }), }; } diff --git a/integrations/react/src/usePrepare.ts b/integrations/react/src/usePrepare.ts index 9f734f075..fcb108558 100644 --- a/integrations/react/src/usePrepare.ts +++ b/integrations/react/src/usePrepare.ts @@ -1,58 +1,24 @@ -import type { - InferActivityParams, - RegisteredActivityName, -} from "@stackflow/config"; -import { useCallback } from "react"; +import { useMemo } from "react"; import { useActivityComponentMap } from "./ActivityComponentMapProvider"; -import { - getContentComponent, - isStructuredActivityComponent, -} from "./StructuredActivityComponentType"; import { useDataLoader } from "./loader"; +import { makePrepare } from "./makePrepare"; import type { Prepare } from "./Prepare"; import { useConfig } from "./useConfig"; +/** + * `stackflow()` 출력의 `prepare`와 동일 로직을 감싸는 얇은 래퍼. + * + * React Context에서 파생되는 세 입력(`config`, `loadData`, `activityComponentMap`)을 + * `makePrepare`에 그대로 넘긴다. 셋 중 하나라도 바뀌지 않는 한 반환 함수의 참조가 + * 안정적이도록 메모이즈한다. + */ export function usePrepare(): Prepare { const config = useConfig(); const loadData = useDataLoader(); const activityComponentMap = useActivityComponentMap(); - return useCallback( - async function prepare( - activityName: K, - activityParams?: InferActivityParams, - ) { - const activityConfig = config.activities.find( - ({ name }) => name === activityName, - ); - const prefetchTasks: Promise[] = []; - - if (!activityConfig) - throw new Error(`Activity ${activityName} is not registered.`); - - if (activityParams && activityConfig.loader) { - prefetchTasks.push( - Promise.resolve(loadData(activityName, activityParams)), - ); - } - - if ("_load" in activityComponentMap[activityName]) { - prefetchTasks.push( - Promise.resolve(activityComponentMap[activityName]._load?.()), - ); - } - - if ( - isStructuredActivityComponent(activityComponentMap[activityName]) && - typeof activityComponentMap[activityName].content === "function" - ) { - prefetchTasks.push( - getContentComponent(activityComponentMap[activityName]).preload(), - ); - } - - await Promise.all(prefetchTasks); - }, + return useMemo( + () => makePrepare({ config, loadData, activityComponentMap }), [config, loadData, activityComponentMap], ); } From 2c19d569e63887339d00468cf5a614c05f5db7ac Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 8 Jun 2026 22:18:04 +0900 Subject: [PATCH 14/15] remove meaningless comment --- integrations/react/src/makePrepare.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/integrations/react/src/makePrepare.ts b/integrations/react/src/makePrepare.ts index 9bf8a23c4..a128301d6 100644 --- a/integrations/react/src/makePrepare.ts +++ b/integrations/react/src/makePrepare.ts @@ -19,15 +19,6 @@ export type MakePrepareInput = { }; }; -/** - * `prepare` 구현 코어. - * - * React Context에 의존하지 않고, `stackflow()` 입력에서 직접 파생되는 세 가지 - * (`config`, `loadData`, `activityComponentMap`)만 받아 `prepare` 함수를 만든다. - * 따라서 React 렌더링 트리 밖(모듈 평가 시점 포함)에서도 호출할 수 있다. - * - * `stackflow()` 출력의 `prepare`와 `usePrepare` 래퍼가 이 단일 구현을 공유한다. - */ export function makePrepare({ config, loadData, From f70e16e06df75b23faaad07f54c89261491b706c Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Mon, 8 Jun 2026 22:26:10 +0900 Subject: [PATCH 15/15] test(react): remove the FEP-2357 prepare test harness and suites Drop the Jest test facility and the prepare/usePrepare spec suites that were set up for FEP-2357, returning all test-only infrastructure to its main-branch state: - delete `prepare.spec.tsx`, `usePrepare.spec.tsx`, and `tsconfig.test.json` - restore `package.json` (remove the `test` script, jest config, and test devDependencies; revert `typecheck` to `tsc --noEmit`) - restore `esbuild.config.js` (revert the `*.spec.*` entry-point exclusion to the plain `./src/**/*` glob) and `tsconfig.json` (drop the spec excludes) - restore `PluginRenderer.tsx` (the spec-motivated `RegisteredActivityName` cast is no longer needed) - restore `yarn.lock` / `.pnp.cjs` to drop the test dependencies The `prepare` implementation (makePrepare, the stackflow() wiring, the usePrepare wrapper, the Prepare type export) and its changeset are unchanged. 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/prepare.spec.tsx | 948 --------------------- integrations/react/src/usePrepare.spec.tsx | 128 --- integrations/react/tsconfig.json | 2 +- integrations/react/tsconfig.test.json | 7 - yarn.lock | 9 - 9 files changed, 5 insertions(+), 1170 deletions(-) delete mode 100644 integrations/react/src/prepare.spec.tsx delete mode 100644 integrations/react/src/usePrepare.spec.tsx delete mode 100644 integrations/react/tsconfig.test.json diff --git a/.pnp.cjs b/.pnp.cjs index bdb5a9b96..6a194ab38 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -7077,21 +7077,12 @@ 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"]\ @@ -7113,19 +7104,10 @@ 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 4aa5882b8..17749d586 100644 --- a/integrations/react/esbuild.config.js +++ b/integrations/react/esbuild.config.js @@ -1,5 +1,3 @@ -const fs = require("node:fs"); -const path = require("node:path"); const { context } = require("esbuild"); const config = require("@stackflow/esbuild-config"); const { @@ -14,28 +12,10 @@ 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, + entryPoints: ["./src/**/*"], outdir: "dist", }), bundle: false, @@ -47,7 +27,7 @@ Promise.all([ ), context({ ...config({ - entryPoints, + entryPoints: ["./src/**/*"], outdir: "dist", }), bundle: true, diff --git a/integrations/react/package.json b/integrations/react/package.json index f3cfa8c55..3182f181d 100644 --- a/integrations/react/package.json +++ b/integrations/react/package.json @@ -28,28 +28,7 @@ "build:js": "node ./esbuild.config.js", "clean": "rimraf dist", "dev": "yarn build:js --watch && yarn build:dts --watch", - "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" - } - } - } - } - ] - } + "typecheck": "tsc --noEmit" }, "dependencies": { "react-fast-compare": "^3.2.2" @@ -58,19 +37,10 @@ "@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 2da7779f4..da3eb8541 100644 --- a/integrations/react/src/PluginRenderer.tsx +++ b/integrations/react/src/PluginRenderer.tsx @@ -1,4 +1,3 @@ -import type { RegisteredActivityName } from "@stackflow/config"; import React, { Component, type ReactNode, Suspense } from "react"; import { useActivityComponentMap } from "./ActivityComponentMapProvider"; import { ActivityProvider } from "./activity"; @@ -38,11 +37,7 @@ const PluginRenderer: React.FC = ({ ...activity, key: activity.id, render(overrideActivity) { - // `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]; + const Activity = activityComponentMap[activity.name]; let output: React.ReactNode = isStructuredActivityComponent( Activity, diff --git a/integrations/react/src/prepare.spec.tsx b/integrations/react/src/prepare.spec.tsx deleted file mode 100644 index 6874a79e2..000000000 --- a/integrations/react/src/prepare.spec.tsx +++ /dev/null @@ -1,948 +0,0 @@ -/** - * `prepare` 런타임 규약 - * - * `stackflow()` 출력의 `prepare(activityName, activityParams?)`는 React 렌더링 - * 트리 밖에서(렌더 이전 포함) activity component chunk와 data loader를 미리 - * 발사한다. 이 파일이 고정하는 계약: - * - * - params 생략 → chunk만, params 전달 → chunk + loader 발사 - * - 반환 Promise는 발사한 모든 작업 완료 시에만 resolve (중간 상태 미노출) - * - stackflow() core store 초기화 이전(React 트리 부재 상태)에도 prepare를 - * 사용할 수 있고, 이후 마운트를 방해하지 않음 - * - 모든 실패는 동기 throw가 아닌 원본 reason 그대로의 reject로 전달되고, - * chunk 실패는 재-prepare 시 재시도된다 - * - prepare는 발사만 담당하며, 결과 소비(loaderData 주입·lazy 렌더)는 기존 - * 내비게이션 경로가 그대로 담당한다 — prepare → push가 일반 push와 동일하게 - * 동작 - * - * loader 디듀프, chunk import 중복 발사, 부분 발사 원자성/취소는 계약이 아닌 - * 구현 상세로 남겨둔 동작이므로, 어느 방향으로도 단언하지 않는다. - * - * usePrepare 래퍼 동등성은 usePrepare.spec.tsx에 있다. - * - * import는 public entry(`./index`)에서만 한다 — `"@stackflow/react"` 패키지명 - * import는 dist(빌드 산출물)를 가리키므로 src 변경 대신 stale artifact를 - * 검증하게 된다. - */ -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으로 허용된다). - * 다른 spec과의 이름 충돌 방지를 위해 `Prepare` 접두사를 사용한다. - * - * 주의: 필수 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 }; - -/** 제어 가능한 비동기 작업 */ -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 내부 구조에 의존하지 않는다. - */ -async function isSettled(p: Promise): Promise { - let settled = false; - p.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); - await flushMicrotasks(); - return settled; -} - -/** - * 인라인 렌더러 플러그인 — `@stackflow/plugin-renderer-basic`은 워크스페이스 - * 순환 의존이라 사용할 수 없다. - */ -const testRendererPlugin: StackflowReactPlugin = () => ({ - key: "test-renderer", - render({ stack }) { - return ( - <> - {stack.render().activities.map((activity) => ( - - {activity.render()} - - ))} - - ); - }, -}); - -/** - * Suspense 래핑 변형 — pending chunk의 lazy 컴포넌트를 마운트하는 테스트는 - * 렌더가 suspend하므로 ``으로 감싼다. - */ -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("기본 규약 (렌더 없이 호출)", () => { - it("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("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("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 발사 검증은 위 테스트들의 규약) - await expect(p).resolves.toBeUndefined(); - }); - - it("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("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("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("미등록 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: 미등록 이름으로 호출한다 (미등록 이름은 타입이 거부하므로 - // 런타임 테스트는 as any로 우회한다) - // 동기 throw라면 이 줄에서 테스트가 실패하므로, 아래 단언이 - // "throw가 아닌 reject" 계약을 함께 고정한다 - const p = prepare("Unknown" as any); - - // then: 해당 메시지의 Error로 reject된다 - await expect(p).rejects.toThrow("Activity Unknown is not registered."); - }); - - it('빈 객체 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가 호출된다 (params를 생략한 경우와 달리) - expect(loader).toHaveBeenCalled(); - }); - }); - - describe("반환 Promise 의미 — 모든 작업 완료 시에만 resolve", () => { - it("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("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("chunk만 완료되고 loader가 미완료인 동안에는 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: 호출 후 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("React 밖 / 렌더 전 호출 가능성", () => { - it(" 렌더 없이(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("렌더 전 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("동시성 · 경쟁 상태 · 실패", () => { - it("동일 activity에 대한 동시 중복 prepare — 두 Promise 모두 작업 완료 후 각각 resolve된다", async () => { - // given: deferred chunk를 가진 lazy activity. import 함수는 호출마다 - // 동일한 deferred.promise를 반환한다 — 구현이 디듀프하든 안 하든 - // 테스트 결과가 같도록(디듀프-불가지 픽스처) - 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의 호출 횟수는 계약이 아니므로 단언하지 않는다) - await expect(p1).resolves.toBeUndefined(); - await expect(p2).resolves.toBeUndefined(); - }); - - it("서로 다른 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("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("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("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 발사 여부는 단언하지 않는다) - await expect(p).rejects.toBe(err); - }); - - it("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("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("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("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("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("발사/소비 분리 — prepare 이후 push는 일반 push와 동일하게 동작", () => { - // 주의: 이 절은 호출 횟수를 단언하지 않는다. loader 디듀프·chunk 중복 발사 - // 여부는 계약이 아니며, 여기서는 prepare의 책임이 발사에서 끝나고 결과 - // 소비(loaderData 주입은 loaderPlugin, chunk 렌더는 lazy 경로)는 기존 - // 내비게이션 경로가 그대로 담당한다는 것만 검증한다. - - it("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("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 호출 횟수는 계약이 아니므로 단언하지 않는다) - expect(await screen.findByText("A content")).toBeTruthy(); - }); - }); -}); diff --git a/integrations/react/src/usePrepare.spec.tsx b/integrations/react/src/usePrepare.spec.tsx deleted file mode 100644 index 442c22492..000000000 --- a/integrations/react/src/usePrepare.spec.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** - * `usePrepare` 래퍼 동등성 - * - * `usePrepare`는 stackflow() 출력 `prepare`와 동일 로직을 감싸는 얇은 래퍼다. - * 반환 함수는 prepare.spec.tsx가 고정한 것과 동일한 관찰 결과(chunk + loader - * 발사, 미등록 activity reject)를 보여야 한다. - * 이 파일은 현행 동작 기준이므로 prepare 구현 이전에도 green이어야 한다. - * - * import는 public entry(`./index`)에서만 한다 — 패키지명 import는 dist(빌드 - * 산출물)를 가리킨다. - */ -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: {}; - } -} - -/** 인라인 렌더러 플러그인 — 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 — 래퍼 동등성", () => { - it("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 함수가 호출된다 - // — stackflow() 출력 prepare와 동일한 관찰 결과 - expect(loader).toHaveBeenCalledWith( - expect.objectContaining({ params: { id: "1" } }), - ); - expect(importFn).toHaveBeenCalled(); - }); - - it("usePrepare가 반환한 함수도 미등록 activity에 동일 에러로 reject된다", async () => { - // given: 초기 activity 내부에서 usePrepare() 반환값을 외부 변수로 캡처 - 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: 미등록 이름으로 호출한다 (미등록 이름은 타입이 거부하므로 - // 런타임 테스트는 as any로 우회한다) - const p = capturedPrepare("Unknown" as any); - - // then: stackflow() 출력 prepare와 동일한 에러로 reject된다 - await expect(p).rejects.toThrow("Activity Unknown is not registered."); - }); -}); diff --git a/integrations/react/tsconfig.json b/integrations/react/tsconfig.json index 1a5f1d213..4ed7abc2b 100644 --- a/integrations/react/tsconfig.json +++ b/integrations/react/tsconfig.json @@ -5,5 +5,5 @@ "rootDir": "./src", "outDir": "./dist" }, - "exclude": ["./dist", "**/*.spec.ts", "**/*.spec.tsx"] + "exclude": ["./dist"] } diff --git a/integrations/react/tsconfig.test.json b/integrations/react/tsconfig.test.json deleted file mode 100644 index 519b0a584..000000000 --- a/integrations/react/tsconfig.test.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true - }, - "exclude": ["./dist"] -} diff --git a/yarn.lock b/yarn.lock index 55fd2eb6b..a9e7e77b7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6027,19 +6027,10 @@ __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"