diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..de2e361 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + format: + name: Check formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - run: bun install --frozen-lockfile + + - run: bun run format:check diff --git a/.gitignore b/.gitignore index c2658d7..504afef 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +package-lock.json diff --git a/bun.lock b/bun.lock index 1cf2542..8b25f1a 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "devDependencies": { "@types/bun": "1.3.9", "@types/react": "19.2.14", + "prettier": "3.5.3", }, }, }, @@ -77,6 +78,8 @@ "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "prettier": ["prettier@3.5.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], diff --git a/docs/adr-001-react-effect.md b/docs/adr-001-react-effect.md index ee78576..5fc53e2 100644 --- a/docs/adr-001-react-effect.md +++ b/docs/adr-001-react-effect.md @@ -64,11 +64,11 @@ Wrap `SubscriptionRef` updates with a Node `EventEmitter`. React subscribes via The mapping is natural: -| `useSyncExternalStore` contract | Effect primitive | -|---|---| -| `subscribe(callback)` | Fork a fiber consuming `ref.changes`, call `callback` on each emission | -| `getSnapshot()` | `Runtime.runSync(SubscriptionRef.get(ref))` | -| unsubscribe (returned by subscribe) | `Fiber.interrupt(fiber)` | +| `useSyncExternalStore` contract | Effect primitive | +| ----------------------------------- | ---------------------------------------------------------------------- | +| `subscribe(callback)` | Fork a fiber consuming `ref.changes`, call `callback` on each emission | +| `getSnapshot()` | `Runtime.runSync(SubscriptionRef.get(ref))` | +| unsubscribe (returned by subscribe) | `Fiber.interrupt(fiber)` | ## Implementation Sketch @@ -89,9 +89,7 @@ export function EffectProvider({ children: ReactNode; }) { return ( - - {children} - + {children} ); } @@ -108,17 +106,13 @@ export function useRuntime(): ManagedRuntime.ManagedRuntime { import { Effect, Fiber, Stream, SubscriptionRef } from "effect"; import { useSyncExternalStore } from "react"; -export function useSubscriptionRef( - ref: SubscriptionRef.SubscriptionRef, -): A { +export function useSubscriptionRef(ref: SubscriptionRef.SubscriptionRef): A { const runtime = useRuntime(); return useSyncExternalStore( (onStoreChange) => { const fiber = runtime.runFork( - ref.changes.pipe( - Stream.runForEach(() => Effect.sync(onStoreChange)), - ), + ref.changes.pipe(Stream.runForEach(() => Effect.sync(onStoreChange))), ); return () => { runtime.runFork(Fiber.interrupt(fiber)); @@ -135,24 +129,18 @@ export function useSubscriptionRef( // main.ts const ShuffleLayer = Layer.succeed(Shuffle, { shuffle: fisherYatesShuffle }); const runtime = ManagedRuntime.make(ShuffleLayer); -const gameRef = runtime.runSync( - newGame().pipe(Effect.andThen(SubscriptionRef.make)), -); +const gameRef = runtime.runSync(newGame().pipe(Effect.andThen(SubscriptionRef.make))); render( - + , ); ``` ```tsx // ui.tsx -export function App({ - gameRef, -}: { - gameRef: SubscriptionRef.SubscriptionRef; -}) { +export function App({ gameRef }: { gameRef: SubscriptionRef.SubscriptionRef }) { const game = useSubscriptionRef(gameRef); // rerenders automatically const runtime = useRuntime(); const [error, setError] = useState(null); @@ -175,10 +163,10 @@ export function App({ ## State Ownership Boundary -| State | Owner | Rationale | -|---|---|---| -| Game (domain) | `SubscriptionRef` in Effect | Single source of truth for engine logic | -| Phase, error message (UI) | React `useState` | Local to the view, not an engine concern | +| State | Owner | Rationale | +| ------------------------- | --------------------------- | ---------------------------------------- | +| Game (domain) | `SubscriptionRef` in Effect | Single source of truth for engine logic | +| Phase, error message (UI) | React `useState` | Local to the view, not an engine concern | This mirrors the Redux convention: shared/domain state in the store, local UI state in components. diff --git a/package.json b/package.json index 9ee2857..af630ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "scripts": { - "start": "bun run src/main.ts" + "start": "bun run src/main.ts", + "format:check": "prettier --check ." }, "dependencies": { "effect": "3.19.19", @@ -9,6 +10,7 @@ }, "devDependencies": { "@types/bun": "1.3.9", - "@types/react": "19.2.14" + "@types/react": "19.2.14", + "prettier": "3.5.3" } }