@fias/arche-sdk 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,8 @@
2
2
 
3
3
  This project is a FIAS platform plugin — a React application that runs in a sandboxed iframe within the FIAS marketplace. This file provides the context AI coding assistants need to build, test, and submit plugins effectively.
4
4
 
5
+ For other AI tool instruction files, see `AGENTS.md` (identical content).
6
+
5
7
  ## Project Structure
6
8
 
7
9
  ```
@@ -181,13 +183,17 @@ function AISummarizer() {
181
183
  });
182
184
  }
183
185
 
186
+ // streamingText holds the accumulated tokens during the call AND continues to
187
+ // hold the full text after the call completes — it is NOT cleared. result.output
188
+ // also contains the full text once invoke() resolves. Render ONE slot only —
189
+ // streamingText while loading, result.output afterwards. Showing both renders
190
+ // the response twice.
184
191
  return (
185
192
  <div>
186
193
  <button onClick={() => summarize('...')} disabled={isLoading}>
187
194
  Summarize
188
195
  </button>
189
- {isLoading && <p>{streamingText}</p>}
190
- {result && <p>{result.output}</p>}
196
+ <p>{isLoading ? streamingText : result?.output}</p>
191
197
  {error && <p>Error: {error.message}</p>}
192
198
  </div>
193
199
  );
@@ -247,12 +253,22 @@ navigateTo('/settings');
247
253
 
248
254
  **Returns:** `StepNavigationApi`
249
255
 
256
+ Call **once** in the top-level App component. Share `currentStep` and `setCurrentStep` with step components via React context — do not call `useStepNavigation` from step components (each call creates an independent state).
257
+
250
258
  ```tsx
251
259
  import { useStepNavigation } from '@fias/arche-sdk';
252
260
 
261
+ // Without persistence — currentStep resets on preview rebuild
253
262
  const { currentStep, setCurrentStep } = useStepNavigation('step-1');
263
+
264
+ // With persistence — currentStep survives preview rebuilds (SDK ≥ 1.7.0)
265
+ const { currentStep, setCurrentStep } = useStepNavigation('step-1', {
266
+ persistKey: 'currentStep',
267
+ });
254
268
  ```
255
269
 
270
+ **Do NOT wrap `currentStep` in `usePersistentState`.** Two independent step-state sources create a bidirectional `useEffect` sync loop that spams `storage_write` and flashes the UI between steps. `persistKey` is the only correct way to persist the current step.
271
+
256
272
  ### `usePersistentState()` — Auto-saving state
257
273
 
258
274
  **Permission:** `storage:sandbox`
@@ -266,6 +282,12 @@ const [count, setCount] = usePersistentState<number>('counter', 0);
266
282
  // Automatically persists to storage on change
267
283
  ```
268
284
 
285
+ Use for domain data (form inputs, selections, AI results). **Do not use for `currentStep`** — use `useStepNavigation({ persistKey })` instead.
286
+
287
+ **Writes are debounced (SDK ≥ 1.8.0).** The in-memory value updates synchronously on every setter call, but the underlying `storage_write` is coalesced with a 250 ms trailing-edge debounce and a 1 s max-wait, and flushed on unmount. This makes the hook safe to call from `requestAnimationFrame`, `mousemove`, and other high-frequency handlers — bursts no longer trip the `storage_write` rate limit.
288
+
289
+ Trade-off: reading the same key via `useFiasStorage().readFile('__state/foo')` immediately after a `setValue` can see a stale value for up to ~250 ms. Read through the hook instead of bypassing it.
290
+
269
291
  ### `fias` — Imperative utilities
270
292
 
271
293
  ```tsx
@@ -336,6 +358,8 @@ These are hard limits enforced by the platform. Code that violates these will fa
336
358
  - `storage_read`: 300/minute
337
359
  - `storage_list`, `storage_delete`: 60/minute
338
360
 
361
+ A separate **runaway-loop detector** also fires if any method is called >50 times in 5 s and blocks that method for 10 s. Errors include the target for diagnostics, e.g. `Runaway loop detected: "storage_write" (path: __state/gameState) called 51 times in 5s.` — if you see this, look at the identified path and debounce the updates at the source.
362
+
339
363
  ### Security Rules (enforced during review)
340
364
 
341
365
  - No `eval()`, `Function()`, `innerHTML`, or dynamic code execution
@@ -416,6 +440,84 @@ npm run submit
416
440
  # First listing: 5000 credits ($50). Re-submissions: 100 credits ($1).
417
441
  ```
418
442
 
443
+ ## Common Pitfalls
444
+
445
+ These are real bugs that have shipped from AI-generated plugins. Avoid them.
446
+
447
+ ### Don't navigate from a `useEffect` that watches derived state
448
+
449
+ If a step component derives a value from context state and uses `useEffect` to redirect when that derived value is missing, the redirect can fire mid-update and yank the user away from a working screen. Common shape:
450
+
451
+ ```tsx
452
+ // ❌ BUG: redirects whenever conversations changes between renders
453
+ const currentConversation = conversations.find((c) => c.id === currentConversationId);
454
+
455
+ useEffect(() => {
456
+ if (!currentConversation) {
457
+ setCurrentStep('list');
458
+ }
459
+ }, [currentConversation, setCurrentStep]); // fires on every conversations change
460
+ ```
461
+
462
+ `currentConversation` is a _derived_ value — its reference changes every time `conversations` changes. The effect re-runs and may redirect during a state transition (e.g., right after the user sends a message and the component is mid-update). The user reports "I sent a message and got bounced back" or "the response never showed up".
463
+
464
+ ```tsx
465
+ // ✅ FIX: gate on the *id* (which is durable) and render a loading state when
466
+ // the conversation can't be found yet — let the user navigate back manually
467
+ // instead of forcing it.
468
+ useEffect(() => {
469
+ if (!currentConversationId) setCurrentStep('list');
470
+ }, [currentConversationId, setCurrentStep]);
471
+
472
+ if (!currentConversation) return <div>Loading conversation…</div>;
473
+ ```
474
+
475
+ ### Use functional updaters when an `await` sits between reads and writes
476
+
477
+ When you read state, `await` something, then write back, the closure captures the _stale_ state. Use the functional form so the setter receives the latest value at the time of the update:
478
+
479
+ ```tsx
480
+ // ❌ BUG: `messages` here is the snapshot from before await
481
+ const messages = currentConversation.messages;
482
+ const response = await invoke({ entityId, input });
483
+ setConversations(
484
+ conversations.map((c) => (c.id === id ? { ...c, messages: [...messages, response] } : c)),
485
+ );
486
+
487
+ // ✅ FIX: functional updater reads fresh state at update time
488
+ await invoke({ entityId, input });
489
+ setConversations((prev) =>
490
+ prev.map((c) => (c.id === id ? { ...c, messages: [...c.messages, response] } : c)),
491
+ );
492
+ ```
493
+
494
+ This matters most for `usePersistentState` on lists: the user can fire many updates quickly, and lost-update bugs are common with snapshot-style updates.
495
+
496
+ ### Render `streamingText` OR `result.output` — never both
497
+
498
+ Already covered in the `useEntityInvocation` section above, but worth repeating: `streamingText` is _not_ cleared after the call completes — it keeps holding the full final text. Showing `{streamingText && ...}` AND `{result && ...}` in separate JSX nodes prints the response twice as soon as `result` arrives. Use one slot: `{isLoading ? streamingText : result?.output}`.
499
+
500
+ ### Verify visually when the user reports a UI bug
501
+
502
+ If a user says "I don't see the response" or "the layout's broken", don't rely solely on `console.log` and `useFiasStorage` reads. Inspect the actual rendered output. The platform exposes `get_preview_screenshot` to AI builders for exactly this reason.
503
+
504
+ ### Don't persist per-frame state
505
+
506
+ For state that updates every frame (game position, drag coordinates, animated values), use plain `useState` — not `usePersistentState`. Persisting every ball-position tick writes unbounded data to durable storage and doesn't survive reloads in any useful way anyway (the ball will be somewhere different when the player resumes).
507
+
508
+ Hook-level debouncing (SDK ≥ 1.8.0) makes `usePersistentState` safe under bursts, but you still shouldn't persist what you'll discard on reload. Keep ephemeral state in `useState` and persist only meaningful checkpoints (final score, selected difficulty, high-score list).
509
+
510
+ ```tsx
511
+ // ❌ Persisting ball position every frame — meaningless on reload
512
+ const [gameState, setGameState] = usePersistentState<GameState>('gameState', initial);
513
+ requestAnimationFrame(() => setGameState(advance(gameState)));
514
+
515
+ // ✅ Ephemeral for live gameplay, persisted for results
516
+ const [gameState, setGameState] = useState<GameState>(initial);
517
+ const [highScores, setHighScores] = usePersistentState<Score[]>('highScores', []);
518
+ // persist highScores only when a run completes
519
+ ```
520
+
419
521
  ## Common Patterns
420
522
 
421
523
  ### Theme-Aware Card Component
@@ -491,7 +593,3 @@ function Settings() {
491
593
  );
492
594
  }
493
595
  ```
494
-
495
- ## See also
496
-
497
- `AGENTS.md` — equivalent guide for non-Claude AI tools in this project.
@@ -0,0 +1,13 @@
1
+ # Multi-Step FIAS Plugin Template
2
+
3
+ Canonical pattern for multi-step plugins. The important bits live in:
4
+
5
+ - `src/App.tsx` — calls `useStepNavigation('step-one', { persistKey: 'currentStep' })` **once**. Single source of truth for the current step.
6
+ - `src/context/AppContext.tsx` — exposes `currentStep` and `setCurrentStep` to step components, plus any domain data via `usePersistentState`.
7
+ - `src/steps/<step-id>/<StepName>.tsx` — step components read/write via context. They do not call `useStepNavigation` themselves.
8
+
9
+ ## Why not `usePersistentState('currentStep', ...)`?
10
+
11
+ That used to look tempting, but it creates two independent step-state sources (one from `useStepNavigation`, one from `usePersistentState`). Any `useEffect` that syncs them turns into a bidirectional ping-pong loop that spams `storage_write` and flashes the UI between steps.
12
+
13
+ Since SDK 1.7.0, `useStepNavigation` handles persistence directly via `{ persistKey }`. That is the only correct way to persist step state. Domain data (form inputs, AI-generated content, user selections) can still use `usePersistentState` — just not `currentStep`.
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "1.0.0",
4
+ "description": "A multi-step FIAS plugin arche",
5
+ "main": "src/index.tsx",
6
+ "archeType": "tool",
7
+ "tags": [],
8
+ "pricing": { "model": "free", "currency": "usd" },
9
+ "permissions": ["theme:read", "storage:sandbox"],
10
+ "sdk": "^1.7.0"
11
+ }
@@ -0,0 +1,12 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>FIAS Plugin</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/index.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "start": "vite & sleep 2 && fias-dev",
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "validate": "tsc --noEmit",
10
+ "dev:harness": "fias-dev",
11
+ "submit": "fias-dev submit"
12
+ },
13
+ "dependencies": {
14
+ "@fias/arche-sdk": "^1.7.0",
15
+ "react": "^19.0.0",
16
+ "react-dom": "^19.0.0"
17
+ },
18
+ "devDependencies": {
19
+ "@fias/plugin-dev-harness": "^1.0.0",
20
+ "@types/react": "^19.0.0",
21
+ "@types/react-dom": "^19.0.0",
22
+ "@vitejs/plugin-react": "^4.0.0",
23
+ "typescript": "^5.3.3",
24
+ "vite": "^6.0.0"
25
+ }
26
+ }
@@ -0,0 +1,38 @@
1
+ import { useFiasTheme, useStepNavigation } from '@fias/arche-sdk';
2
+ import { AppProvider } from './context/AppContext';
3
+ import { StepOne } from './steps/step-one/StepOne';
4
+ import { StepTwo } from './steps/step-two/StepTwo';
5
+
6
+ function AppContent() {
7
+ const theme = useFiasTheme();
8
+
9
+ // Single source of truth for step navigation.
10
+ // persistKey makes currentStep survive preview rebuilds via bridge storage.
11
+ // Do NOT also wrap currentStep in usePersistentState — two sources create
12
+ // a bidirectional useEffect sync loop that spams storage and flashes the UI.
13
+ const { currentStep, setCurrentStep } = useStepNavigation('step-one', {
14
+ persistKey: 'currentStep',
15
+ });
16
+
17
+ if (!theme) return null;
18
+
19
+ return (
20
+ <AppProvider currentStep={currentStep} setCurrentStep={setCurrentStep}>
21
+ <div
22
+ style={{
23
+ minHeight: '100vh',
24
+ backgroundColor: theme.colors.background,
25
+ color: theme.colors.text,
26
+ fontFamily: theme.fonts.body,
27
+ padding: theme.spacing.lg,
28
+ }}
29
+ >
30
+ {currentStep === 'step-two' ? <StepTwo /> : <StepOne />}
31
+ </div>
32
+ </AppProvider>
33
+ );
34
+ }
35
+
36
+ export function App() {
37
+ return <AppContent />;
38
+ }
@@ -0,0 +1,36 @@
1
+ import { createContext, useContext, ReactNode } from 'react';
2
+ import { usePersistentState } from '@fias/arche-sdk';
3
+
4
+ interface AppContextValue {
5
+ currentStep: string | null;
6
+ setCurrentStep: (step: string | null) => void;
7
+ // Domain data — safe to persist with usePersistentState.
8
+ notes: string;
9
+ setNotes: (value: string) => void;
10
+ }
11
+
12
+ const AppContext = createContext<AppContextValue | null>(null);
13
+
14
+ interface AppProviderProps {
15
+ children: ReactNode;
16
+ currentStep: string | null;
17
+ setCurrentStep: (step: string | null) => void;
18
+ }
19
+
20
+ export function AppProvider({ children, currentStep, setCurrentStep }: AppProviderProps) {
21
+ // Step navigation comes in from useStepNavigation (in App.tsx) — the single
22
+ // source of truth. Only domain data lives in usePersistentState here.
23
+ const [notes, setNotes] = usePersistentState<string>('notes', '');
24
+
25
+ return (
26
+ <AppContext.Provider value={{ currentStep, setCurrentStep, notes, setNotes }}>
27
+ {children}
28
+ </AppContext.Provider>
29
+ );
30
+ }
31
+
32
+ export function useAppContext(): AppContextValue {
33
+ const ctx = useContext(AppContext);
34
+ if (!ctx) throw new Error('useAppContext must be used within AppProvider');
35
+ return ctx;
36
+ }
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import { FiasProvider } from '@fias/arche-sdk';
4
+ import { App } from './App';
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <FiasProvider>
9
+ <App />
10
+ </FiasProvider>
11
+ </React.StrictMode>,
12
+ );
@@ -0,0 +1,43 @@
1
+ import { useFiasTheme } from '@fias/arche-sdk';
2
+ import { useAppContext } from '../../context/AppContext';
3
+
4
+ export function StepOne() {
5
+ const theme = useFiasTheme();
6
+ const { notes, setNotes, setCurrentStep } = useAppContext();
7
+
8
+ if (!theme) return null;
9
+
10
+ return (
11
+ <div>
12
+ <h1 style={{ fontFamily: theme.fonts.heading }}>Step 1 — Notes</h1>
13
+ <textarea
14
+ value={notes}
15
+ onChange={(e) => setNotes(e.target.value)}
16
+ rows={6}
17
+ style={{
18
+ width: '100%',
19
+ padding: theme.spacing.sm,
20
+ backgroundColor: theme.colors.surface,
21
+ color: theme.colors.text,
22
+ border: `${theme.components.borderWidth} solid ${theme.colors.border}`,
23
+ borderRadius: theme.components.inputRadius,
24
+ }}
25
+ />
26
+ <button
27
+ onClick={() => setCurrentStep('step-two')}
28
+ disabled={notes.trim().length === 0}
29
+ style={{
30
+ marginTop: theme.spacing.md,
31
+ padding: `${theme.spacing.sm} ${theme.spacing.lg}`,
32
+ backgroundColor: theme.colors.primary,
33
+ color: theme.colors.primaryText,
34
+ border: 'none',
35
+ borderRadius: theme.components.buttonRadius,
36
+ cursor: notes.trim().length === 0 ? 'not-allowed' : 'pointer',
37
+ }}
38
+ >
39
+ Next
40
+ </button>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,40 @@
1
+ import { useFiasTheme } from '@fias/arche-sdk';
2
+ import { useAppContext } from '../../context/AppContext';
3
+
4
+ export function StepTwo() {
5
+ const theme = useFiasTheme();
6
+ const { notes, setCurrentStep } = useAppContext();
7
+
8
+ if (!theme) return null;
9
+
10
+ return (
11
+ <div>
12
+ <h1 style={{ fontFamily: theme.fonts.heading }}>Step 2 — Review</h1>
13
+ <pre
14
+ style={{
15
+ padding: theme.spacing.md,
16
+ backgroundColor: theme.colors.surface,
17
+ color: theme.colors.text,
18
+ borderRadius: theme.components.cardRadius,
19
+ whiteSpace: 'pre-wrap',
20
+ }}
21
+ >
22
+ {notes}
23
+ </pre>
24
+ <button
25
+ onClick={() => setCurrentStep('step-one')}
26
+ style={{
27
+ marginTop: theme.spacing.md,
28
+ padding: `${theme.spacing.sm} ${theme.spacing.lg}`,
29
+ backgroundColor: theme.colors.surface,
30
+ color: theme.colors.text,
31
+ border: `${theme.components.borderWidth} solid ${theme.colors.border}`,
32
+ borderRadius: theme.components.buttonRadius,
33
+ cursor: 'pointer',
34
+ }}
35
+ >
36
+ Back
37
+ </button>
38
+ </div>
39
+ );
40
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true
15
+ },
16
+ "include": ["src"]
17
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ build: {
7
+ outDir: 'dist',
8
+ rollupOptions: {
9
+ input: 'index.html',
10
+ },
11
+ },
12
+ server: {
13
+ port: 3100,
14
+ cors: true,
15
+ headers: { 'Access-Control-Allow-Origin': '*' },
16
+ hmr: { host: 'localhost' },
17
+ },
18
+ });