gemi 0.59.0 → 0.61.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.
- package/dist/ai/Agent.d.ts +533 -25
- package/dist/ai/Agent.d.ts.map +1 -1
- package/dist/ai/Agent.test-d.d.ts +2 -0
- package/dist/ai/Agent.test-d.d.ts.map +1 -0
- package/dist/ai/AgentController.d.ts +275 -6
- package/dist/ai/AgentController.d.ts.map +1 -1
- package/dist/ai/AgentProvider.d.ts +255 -0
- package/dist/ai/AgentProvider.d.ts.map +1 -0
- package/dist/ai/Schema.d.ts +125 -0
- package/dist/ai/Schema.d.ts.map +1 -0
- package/dist/ai/Schema.test-d.d.ts +2 -0
- package/dist/ai/Schema.test-d.d.ts.map +1 -0
- package/dist/ai/client/index.d.ts +24 -0
- package/dist/ai/client/index.d.ts.map +1 -0
- package/dist/ai/client/index.js +1053 -0
- package/dist/ai/client/index.js.map +1 -0
- package/dist/ai/client/reducer.d.ts +127 -0
- package/dist/ai/client/reducer.d.ts.map +1 -0
- package/dist/ai/client/reducer.test-d.d.ts +2 -0
- package/dist/ai/client/reducer.test-d.d.ts.map +1 -0
- package/dist/ai/client/sse.d.ts +54 -0
- package/dist/ai/client/sse.d.ts.map +1 -0
- package/dist/ai/example.d.ts +129 -0
- package/dist/ai/example.d.ts.map +1 -0
- package/dist/ai/index.d.ts +35 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +20 -0
- package/dist/ai/index.js.map +23 -0
- package/dist/ai/live/harness.d.ts +137 -0
- package/dist/ai/live/harness.d.ts.map +1 -0
- package/dist/ai/providers/call.d.ts +42 -0
- package/dist/ai/providers/call.d.ts.map +1 -0
- package/dist/ai/providers/capabilities.d.ts +50 -0
- package/dist/ai/providers/capabilities.d.ts.map +1 -0
- package/dist/ai/providers/errors.d.ts +37 -0
- package/dist/ai/providers/errors.d.ts.map +1 -0
- package/dist/ai/providers/fakeProvider.d.ts +35 -0
- package/dist/ai/providers/fakeProvider.d.ts.map +1 -0
- package/dist/ai/providers/http.d.ts +46 -0
- package/dist/ai/providers/http.d.ts.map +1 -0
- package/dist/ai/providers/request.d.ts +69 -0
- package/dist/ai/providers/request.d.ts.map +1 -0
- package/dist/ai/providers/stream.d.ts +42 -0
- package/dist/ai/providers/stream.d.ts.map +1 -0
- package/dist/ai/signing.d.ts +195 -0
- package/dist/ai/signing.d.ts.map +1 -0
- package/dist/ai/store/LiveRuns.d.ts +142 -0
- package/dist/ai/store/LiveRuns.d.ts.map +1 -0
- package/dist/ai/store/MemoryAgentStore.d.ts +61 -0
- package/dist/ai/store/MemoryAgentStore.d.ts.map +1 -0
- package/dist/ai/store/index.d.ts +4 -0
- package/dist/ai/store/index.d.ts.map +1 -0
- package/dist/ai/store/sse.d.ts +58 -0
- package/dist/ai/store/sse.d.ts.map +1 -0
- package/dist/ai/store/stubAgentRun.d.ts +56 -0
- package/dist/ai/store/stubAgentRun.d.ts.map +1 -0
- package/dist/ai/types.d.ts +443 -0
- package/dist/ai/types.d.ts.map +1 -0
- package/dist/ai/useChat.d.ts +253 -7
- package/dist/ai/useChat.d.ts.map +1 -1
- package/dist/bin/gemi.js +500 -16
- package/dist/bin/gemi.js.map +10 -5
- package/dist/chunk-1aqzcgfr.js +5 -0
- package/dist/chunk-1aqzcgfr.js.map +10 -0
- package/dist/{chunk-get4mkx8.js → chunk-1b7e9rj7.js} +2 -2
- package/dist/{chunk-get4mkx8.js.map → chunk-1b7e9rj7.js.map} +1 -1
- package/dist/{chunk-gfma8e03.js → chunk-3aemqfdr.js} +2 -2
- package/dist/{chunk-gfma8e03.js.map → chunk-3aemqfdr.js.map} +1 -1
- package/dist/{chunk-j06g4sqc.js → chunk-528n3vgy.js} +2 -2
- package/dist/{chunk-j06g4sqc.js.map → chunk-528n3vgy.js.map} +1 -1
- package/dist/{chunk-2khdxyjb.js → chunk-57a0nqfj.js} +1 -1
- package/dist/chunk-57a0nqfj.js.map +10 -0
- package/dist/{chunk-c40n5r4v.js → chunk-5mhcwnyd.js} +2 -2
- package/dist/{chunk-c40n5r4v.js.map → chunk-5mhcwnyd.js.map} +1 -1
- package/dist/{chunk-gwchvzdp.js → chunk-71pk1mxx.js} +2 -2
- package/dist/{chunk-gwchvzdp.js.map → chunk-71pk1mxx.js.map} +1 -1
- package/dist/{chunk-spbgpndn.js → chunk-7t1hjs9f.js} +2 -2
- package/dist/{chunk-spbgpndn.js.map → chunk-7t1hjs9f.js.map} +1 -1
- package/dist/{chunk-9gsdcjt7.js → chunk-7xvaace2.js} +3 -3
- package/dist/{chunk-9gsdcjt7.js.map → chunk-7xvaace2.js.map} +1 -1
- package/dist/{chunk-fxy42w6n.js → chunk-8ag0da2s.js} +2 -2
- package/dist/{chunk-fxy42w6n.js.map → chunk-8ag0da2s.js.map} +1 -1
- package/dist/chunk-8r8epsef.js +5 -0
- package/dist/chunk-8r8epsef.js.map +11 -0
- package/dist/chunk-91cj3nxk.js +6 -0
- package/dist/{chunk-txhcx69q.js.map → chunk-91cj3nxk.js.map} +2 -2
- package/dist/{chunk-0fm6jh9b.js → chunk-9nmvm20t.js} +2 -2
- package/dist/{chunk-0fm6jh9b.js.map → chunk-9nmvm20t.js.map} +1 -1
- package/dist/{chunk-98a3k7bp.js → chunk-9xpa7dpy.js} +2 -2
- package/dist/{chunk-98a3k7bp.js.map → chunk-9xpa7dpy.js.map} +1 -1
- package/dist/{chunk-qva4841r.js → chunk-a1exbqcq.js} +3 -3
- package/dist/{chunk-qva4841r.js.map → chunk-a1exbqcq.js.map} +1 -1
- package/dist/{chunk-cw9y6k15.js → chunk-bb19bwg6.js} +2 -2
- package/dist/{chunk-cw9y6k15.js.map → chunk-bb19bwg6.js.map} +1 -1
- package/dist/{chunk-3gvjn3q4.js → chunk-cf7bvd12.js} +1 -1
- package/dist/{chunk-rkbv3df7.js → chunk-djp2xeqe.js} +2 -2
- package/dist/{chunk-rkbv3df7.js.map → chunk-djp2xeqe.js.map} +1 -1
- package/dist/chunk-ds44bqr9.js +4 -0
- package/dist/{chunk-4mcyyh1v.js.map → chunk-ds44bqr9.js.map} +4 -9
- package/dist/{chunk-wzvs3sym.js → chunk-exndjhza.js} +3 -3
- package/dist/{chunk-wzvs3sym.js.map → chunk-exndjhza.js.map} +1 -1
- package/dist/{chunk-f6dd4gd8.js → chunk-f233yzxf.js} +2 -2
- package/dist/{chunk-f6dd4gd8.js.map → chunk-f233yzxf.js.map} +1 -1
- package/dist/chunk-fz5g2z6h.js +4 -0
- package/dist/{chunk-fbvvqf9b.js.map → chunk-fz5g2z6h.js.map} +2 -2
- package/dist/{chunk-vj9538yn.js → chunk-gcszdwcb.js} +2 -2
- package/dist/{chunk-vj9538yn.js.map → chunk-gcszdwcb.js.map} +1 -1
- package/dist/{chunk-pkjq9833.js → chunk-htesx7ym.js} +4 -4
- package/dist/{chunk-pkjq9833.js.map → chunk-htesx7ym.js.map} +1 -1
- package/dist/chunk-hyxmmj9b.js +5 -0
- package/dist/{chunk-n412aa9s.js.map → chunk-hyxmmj9b.js.map} +2 -2
- package/dist/{chunk-stq96kya.js → chunk-k2sjvt0c.js} +2 -2
- package/dist/{chunk-stq96kya.js.map → chunk-k2sjvt0c.js.map} +1 -1
- package/dist/{chunk-zhbrkpb3.js → chunk-k75phgj4.js} +4 -4
- package/dist/{chunk-zhbrkpb3.js.map → chunk-k75phgj4.js.map} +1 -1
- package/dist/{chunk-y64j80v9.js → chunk-m0tp7zjp.js} +2 -2
- package/dist/{chunk-y64j80v9.js.map → chunk-m0tp7zjp.js.map} +1 -1
- package/dist/{chunk-06j6rsew.js → chunk-m45j7p1y.js} +2 -2
- package/dist/{chunk-06j6rsew.js.map → chunk-m45j7p1y.js.map} +1 -1
- package/dist/chunk-mca9wsvs.js +5 -0
- package/dist/{chunk-z2tcxwyr.js.map → chunk-mca9wsvs.js.map} +3 -4
- package/dist/{chunk-tey1xayb.js → chunk-ms13evzp.js} +2 -2
- package/dist/{chunk-tey1xayb.js.map → chunk-ms13evzp.js.map} +1 -1
- package/dist/{chunk-bn1v4sfs.js → chunk-r962ae93.js} +2 -2
- package/dist/{chunk-bn1v4sfs.js.map → chunk-r962ae93.js.map} +1 -1
- package/dist/{chunk-23h0dmx2.js → chunk-s41ees18.js} +2 -2
- package/dist/{chunk-23h0dmx2.js.map → chunk-s41ees18.js.map} +1 -1
- package/dist/chunk-snb68dgr.js +4 -0
- package/dist/{chunk-hwhw98hc.js.map → chunk-snb68dgr.js.map} +1 -1
- package/dist/chunk-sz051605.js +5 -0
- package/dist/chunk-sz051605.js.map +14 -0
- package/dist/{chunk-2cwcfwg3.js → chunk-tr3cbx8k.js} +2 -2
- package/dist/{chunk-2cwcfwg3.js.map → chunk-tr3cbx8k.js.map} +2 -2
- package/dist/{chunk-dgasxgsm.js → chunk-vqcswg7h.js} +2 -2
- package/dist/{chunk-dgasxgsm.js.map → chunk-vqcswg7h.js.map} +2 -2
- package/dist/{chunk-zqsfanvk.js → chunk-wpb1xpdp.js} +2 -2
- package/dist/{chunk-zqsfanvk.js.map → chunk-wpb1xpdp.js.map} +1 -1
- package/dist/{chunk-z1e55w67.js → chunk-ybqss0jy.js} +2 -2
- package/dist/{chunk-z1e55w67.js.map → chunk-ybqss0jy.js.map} +1 -1
- package/dist/{chunk-cejf873g.js → chunk-yk5wqmyh.js} +2 -2
- package/dist/{chunk-cejf873g.js.map → chunk-yk5wqmyh.js.map} +1 -1
- package/dist/{chunk-8kj3zrm9.js → chunk-ywntv8yw.js} +4 -4
- package/dist/{chunk-8kj3zrm9.js.map → chunk-ywntv8yw.js.map} +1 -1
- package/dist/chunks/{ThemeProvider-ByU4BQdL.js → ThemeProvider-BZ2SsSZ3.js} +60 -39
- package/dist/chunks/ThemeProvider-BZ2SsSZ3.js.map +1 -0
- package/dist/chunks/useParams-BN3XXfmG.js +20 -0
- package/dist/chunks/useParams-BN3XXfmG.js.map +1 -0
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/useDictionary.d.ts.map +1 -1
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +2 -2
- package/dist/config/index.js.map +3 -3
- package/dist/database/index.js +1 -1
- package/dist/facades/index.js +2 -2
- package/dist/facades/index.js.map +1 -1
- package/dist/http/ApiRouter.d.ts +30 -2
- package/dist/http/ApiRouter.d.ts.map +1 -1
- package/dist/http/index.js +2 -2
- package/dist/http/index.js.map +1 -1
- package/dist/i18n/defineDictionary.d.ts +7 -4
- package/dist/i18n/defineDictionary.d.ts.map +1 -1
- package/dist/i18n/dictionaryRegistry.d.ts +38 -14
- package/dist/i18n/dictionaryRegistry.d.ts.map +1 -1
- package/dist/i18n/dictionaryRuntime.js +1 -1
- package/dist/i18n/index.js +2 -2
- package/dist/i18n/index.js.map +2 -2
- package/dist/kernel/index.js +2 -2
- package/dist/kernel/index.js.map +2 -2
- package/dist/orm/index.js +2 -2
- package/dist/orm/index.js.map +2 -2
- package/dist/server/index.js +1 -1
- package/dist/services/index.js +2 -2
- package/dist/services/index.js.map +2 -2
- package/dist/testing/index.js +2 -1
- package/dist/testing/index.js.map +1 -1
- package/package.json +5 -2
- package/skills/gemi-react-best-practices/SKILL.md +231 -0
- package/skills/gemi-react-best-practices/rules/_sections.md +56 -0
- package/skills/gemi-react-best-practices/rules/_template.md +28 -0
- package/skills/gemi-react-best-practices/rules/bundle-deep-imports.md +48 -0
- package/skills/gemi-react-best-practices/rules/bundle-mount-gate-heavy-panels.md +63 -0
- package/skills/gemi-react-best-practices/rules/client-form-vs-mutation-hooks.md +57 -0
- package/skills/gemi-react-best-practices/rules/client-loading-error-exports.md +54 -0
- package/skills/gemi-react-best-practices/rules/client-no-effect-data-flow.md +68 -0
- package/skills/gemi-react-best-practices/rules/client-typed-links.md +51 -0
- package/skills/gemi-react-best-practices/rules/controller-authorize-every-tenant-read.md +65 -0
- package/skills/gemi-react-best-practices/rules/controller-parse-request-at-the-boundary.md +54 -0
- package/skills/gemi-react-best-practices/rules/controller-redirect-facade-throws.md +68 -0
- package/skills/gemi-react-best-practices/rules/controller-request-schema.md +61 -0
- package/skills/gemi-react-best-practices/rules/controller-throw-framework-errors.md +57 -0
- package/skills/gemi-react-best-practices/rules/i18n-define-dictionary-inline.md +58 -0
- package/skills/gemi-react-best-practices/rules/orm-analytics-connection.md +53 -0
- package/skills/gemi-react-best-practices/rules/orm-include-not-n-plus-one.md +56 -0
- package/skills/gemi-react-best-practices/rules/orm-paginate-helper.md +69 -0
- package/skills/gemi-react-best-practices/rules/orm-plain-rows-by-default.md +55 -0
- package/skills/gemi-react-best-practices/rules/orm-select-narrow.md +58 -0
- package/skills/gemi-react-best-practices/rules/orm-transaction-no-io.md +54 -0
- package/skills/gemi-react-best-practices/rules/orm-transaction-sequential.md +64 -0
- package/skills/gemi-react-best-practices/rules/payload-dont-overprefetch.md +54 -0
- package/skills/gemi-react-best-practices/rules/payload-instant-vs-prefetch.md +58 -0
- package/skills/gemi-react-best-practices/rules/payload-minimal-view-props.md +51 -0
- package/skills/gemi-react-best-practices/rules/payload-parallel-controller-work.md +56 -0
- package/skills/gemi-react-best-practices/rules/payload-prefetch-late-queries.md +58 -0
- package/skills/gemi-react-best-practices/rules/payload-prefetch-mirrors-usequery.md +52 -0
- package/skills/gemi-react-best-practices/rules/query-debounce-search-variant.md +52 -0
- package/skills/gemi-react-best-practices/rules/query-keep-previous-data.md +40 -0
- package/skills/gemi-react-best-practices/rules/query-lazy-vs-mount-gate.md +54 -0
- package/skills/gemi-react-best-practices/rules/query-mutate-over-refetch.md +55 -0
- package/skills/gemi-react-best-practices/rules/query-no-hand-rolled-fetch.md +60 -0
- package/skills/gemi-react-best-practices/rules/query-revalidate-on-focus.md +44 -0
- package/skills/gemi-react-best-practices/rules/query-share-cache-key.md +51 -0
- package/skills/gemi-react-best-practices/rules/query-suspense-default.md +52 -0
- package/skills/gemi-react-best-practices/rules/routing-cache-policy-constants.md +53 -0
- package/skills/gemi-react-best-practices/rules/routing-middleware-dsl.md +60 -0
- package/skills/gemi-react-best-practices/rules/routing-resource-routes.md +59 -0
- package/skills/gemi-react-best-practices/rules/routing-routers-are-classes.md +55 -0
- package/skills/gemi-react-best-practices/rules/service-lazy-not-module-scope.md +63 -0
- package/skills/gemi-react-best-practices/rules/service-queue-is-in-memory.md +52 -0
- package/skills/gemi-react-best-practices/rules/service-static-token-and-name.md +52 -0
- package/skills/gemi-react-best-practices/rules/structure-discovered-vs-registered.md +71 -0
- package/skills/gemi-react-best-practices/rules/structure-do-not-reinvent-the-framework.md +58 -0
- package/skills/gemi-react-best-practices/rules/testing-assert-behaviour-over-markup.md +54 -0
- package/skills/gemi-react-best-practices/rules/testing-match-the-suite.md +57 -0
- package/skills/gemi-react-best-practices/rules/testing-page-seeds-real-inputs.md +65 -0
- package/dist/chunk-2khdxyjb.js.map +0 -10
- package/dist/chunk-4mcyyh1v.js +0 -4
- package/dist/chunk-fbvvqf9b.js +0 -4
- package/dist/chunk-hwhw98hc.js +0 -4
- package/dist/chunk-n412aa9s.js +0 -5
- package/dist/chunk-q0y0j3ne.js +0 -5
- package/dist/chunk-q0y0j3ne.js.map +0 -11
- package/dist/chunk-txhcx69q.js +0 -6
- package/dist/chunk-z2tcxwyr.js +0 -5
- package/dist/chunks/ThemeProvider-ByU4BQdL.js.map +0 -1
- /package/dist/{chunk-3gvjn3q4.js.map → chunk-cf7bvd12.js.map} +0 -0
package/dist/testing/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { A as Action, E as ServerDataContext, F as
|
|
1
|
+
import { A as Action, E as ServerDataContext, F as QueryManagerContext, I as QueryManagerProvider, M as toVariantKey, N as applyParams, T as I18nProvider, a as m, c as ClientRouterContext, j as createMemoryHistory, k as ProgressManager, o as RouteTransitionProvider, r as WebSocketContext, t as ThemeProvider, z as Subject } from "../chunks/ThemeProvider-BZ2SsSZ3.js";
|
|
2
|
+
import { r as RouteStateProvider } from "../chunks/useParams-BN3XXfmG.js";
|
|
2
3
|
import { Suspense, useContext, useMemo, useRef, useState } from "react";
|
|
3
4
|
import { jsx } from "react/jsx-runtime";
|
|
4
5
|
//#region testing/Page.tsx
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../testing/Page.tsx"],"sourcesContent":["import {\n Suspense,\n useContext,\n useMemo,\n useRef,\n useState,\n type ComponentType,\n type PropsWithChildren,\n type ReactNode,\n} from \"react\";\nimport { ErrorBoundary, type FallbackProps } from \"react-error-boundary\";\nimport { Action, createMemoryHistory } from \"history\";\n\nimport type { User } from \"../auth/types\";\nimport { ClientRouterContext } from \"../client/ClientRouterContext\";\nimport { I18nProvider } from \"../client/I18nContext\";\nimport { ProgressManager } from \"../client/ProgressManager\";\nimport {\n QueryManagerContext,\n QueryManagerProvider,\n type QueryConfig,\n} from \"../client/QueryManagerContext\";\nimport {\n RouteStateProvider,\n type PageData,\n type RouteState,\n} from \"../client/RouteStateContext\";\nimport { RouteTransitionProvider } from \"../client/RouteTransitionProvider\";\nimport {\n ServerDataContext,\n type ServerDataContextValue,\n} from \"../client/ServerDataProvider\";\nimport { ThemeProvider } from \"../client/ThemeProvider\";\nimport type { ClientFeatureKey } from \"../client/rpc\";\nimport type { Breadcrumb } from \"../client/useBreadcrumbs\";\nimport { WebSocketContext } from \"../client/WebsocketContext\";\nimport { applyParams } from \"../utils/applyParams\";\nimport { Subject } from \"../utils/Subject\";\nimport { toVariantKey } from \"../utils/variantKey\";\n\n/**\n * A dictionary as `Dictionary.create` produces it: a name and a\n * `{ key: { locale: string } }` map.\n *\n * Typed structurally rather than as `Dictionary<T>` for two reasons. It keeps\n * `gemi/testing` from importing `gemi/i18n` — a server entry, which reaches the\n * `Lang` facade and through it the container. And it means an object literal of\n * the same shape is accepted, so a test never *has* to import the app's\n * dictionaries at all.\n *\n * Only `name` and `dictionary` are read — both plain data. `Dictionary`'s\n * server-only methods (`render`, `reference`, which throw once `window` is\n * defined) are never called from here.\n */\nexport interface PageDictionary {\n name: string;\n dictionary: Record<string, Record<string, string>>;\n}\n\nexport interface PageProps {\n /**\n * The URL the view is mounted at. A template (`/app/:orgId/chat`) is resolved\n * against `params`, so the same string can be pasted from the router.\n */\n pathname?: string;\n /** Route params, as the router would have parsed them out of `pathname`. */\n params?: Record<string, string>;\n /** `\"?tab=recent\"`, `\"tab=recent\"`, or `{ tab: \"recent\" }`. */\n searchParams?: string | Record<string, string | number | boolean>;\n hash?: string;\n /** The locale `useTranslator` and `useLocale` report. */\n locale?: string;\n /**\n * The app's default locale. Links and `useNavigate` omit the locale segment\n * for it, so leaving this equal to `locale` (the default) keeps hrefs\n * unprefixed — set it to seed a page being viewed in a non-default locale.\n */\n defaultLocale?: string;\n supportedLocales?: string[];\n /**\n * Dictionaries the components under test translate against — the app's own,\n * or literals of the same shape.\n *\n * Note what importing the app's costs: `app/i18n/index.ts` imports\n * `gemi/i18n`, a server entry, so the test file pulls the container and\n * `node:async_hooks` into its module graph. Nothing there runs (and nothing\n * here calls it), so it is fine under any runner that has Node builtins —\n * vitest, `bun test` — and not under one that renders in a real browser. Use\n * `translations` there, which is the shape the client actually receives.\n */\n dictionaries?: PageDictionary[];\n /**\n * Translations for the current locale, already resolved: dictionary name →\n * key → string. This is the shape the server serializes onto the page, so it\n * is what the client reads at runtime — and it imports nothing.\n *\n * ```tsx\n * <Page translations={{ Chat: { greeting: \"Hello {{name}}\" } }}>\n * ```\n *\n * Merged over `dictionaries`, so the two compose: seed from the app's real\n * dictionary and override the one key a test is about.\n */\n translations?: Record<string, Record<string, string>>;\n /**\n * Data `useQuery` finds already cached, keyed by API path — the path passed\n * to `useQuery`, without the `/api` prefix the client adds when it fetches.\n * A key may carry a query string (`\"/lists?page=2\"`) to seed one search\n * variant.\n *\n * The cache is keyed by the *resolved* path, so a key's `:params` are\n * resolved against the page's `params` — and a key naming one the page does\n * not carry is an error, naming the key. That case is common enough to be\n * worth the loud failure: a query often takes its params from the call site\n * rather than the route (`useQuery(\"/orgs/:orgId/lists\", { params: { orgId } })`\n * with `orgId` in component state), and the right seed for it is the resolved\n * path, `\"/orgs/abc/lists\"`.\n *\n * Seeded at mount only: a component that mutates or refetches its data owns\n * the cache from then on.\n */\n queryData?: Record<string, unknown>;\n /** App-wide `useQuery` defaults, as `createRoot` threads them. */\n queryConfig?: QueryConfig;\n /**\n * What `useUser()` reports. Defaults to `null` — an anonymous visitor — and\n * either way the hook resolves from here without issuing a request.\n */\n user?: Partial<User> | null;\n /** What `useBreadcrumbs()` returns, in order. */\n breadcrumbs?: Breadcrumb[];\n /**\n * What `useFeature()` reports, keyed by feature key. Anything left out reads\n * as off, exactly as an undeclared key does in a real request.\n *\n * A test seeds the *outcome*, not the declaration: `app/features` owns the\n * targeting and the rollout, and re-deriving those here would mean building a\n * request context — a user, a `session_id`, a bot verdict — to assert a\n * branch that only cares which way the feature came out. Cover the targeting\n * where it lives, with a server test.\n */\n features?: Partial<Record<ClientFeatureKey, boolean>>;\n /**\n * The theme `useTheme()` starts on. The app reads a visitor's stored choice\n * out of `localStorage`; a test has no session to have made one in, so this\n * seeds it without writing to storage. `setTheme` works from either.\n */\n theme?: \"light\" | \"dark\" | \"system\";\n /**\n * Fallback for the `Suspense` boundary wrapped around the children — the\n * stand-in for the view's own `Loading` export, which the real router\n * supplies. Defaults to `null`.\n */\n fallback?: ReactNode;\n /**\n * Renders in place of the children when one throws, mirroring a view's\n * `Error` export. Without it a thrown render (an errored suspense query,\n * say) propagates out of `render()` and fails the test, which is usually\n * what you want.\n */\n errorFallback?: ComponentType<FallbackProps>;\n /**\n * Called when something navigates — a `Link` click, `useNavigate().push`.\n * The navigation is reported, not performed: `<Page>` renders one route and\n * does not re-resolve the tree, so `useLocation()` keeps reporting the\n * pathname it was given.\n */\n onNavigate?: (href: string, action: \"push\" | \"replace\") => void;\n}\n\n/** `\"?a=b\"` (or `\"\"`) from any of the shapes `searchParams` accepts. */\nfunction toSearchString(search: PageProps[\"searchParams\"]): string {\n if (!search) return \"\";\n const query =\n typeof search === \"string\"\n ? search.replace(/^\\?/, \"\")\n : new URLSearchParams(\n Object.entries(search).map(([key, value]) => [key, String(value)]),\n ).toString();\n return query ? `?${query}` : \"\";\n}\n\n/**\n * The `:params` a seed key needs supplied. Wildcards (`:rest*`) are excluded —\n * `applyParams` drops those when missing, which is a resolution rather than a\n * failure.\n *\n * Optional params (`:id?`) cannot reach here at all: a key is split at its\n * first `?` to separate the search string, so `\"/lists/:folderId?\"` arrives as\n * the path `/lists/:folderId`. Seed the resolved path for those.\n */\nfunction requiredParamsOf(path: string): string[] {\n return Array.from(path.matchAll(/:([^/]+)/g), ([, name]) => name).filter(\n (name) => !name.endsWith(\"*\"),\n );\n}\n\n/**\n * Fails a `queryData` key whose `:params` the page does not carry, saying so\n * in terms of the key.\n *\n * Without this the two documented runners disagree, and neither is legible.\n * `applyParams` throws `Missing parameter: orgId` under `import.meta.env.DEV`\n * (vitest) — a router-utility stack trace out of `<Page>`'s render, before\n * anything mounts, naming nothing that appears in the test. Under `bun test`\n * that flag is falsy, so it logs and seeds the literal `/orgs/undefined/lists`\n * instead: the seed misses, the query goes to the network, and the assertion\n * runs against a loading state.\n *\n * The case is common because a query's params often come from the call site\n * rather than the route — `useQuery(\"/orgs/:orgId/lists\", { params: { orgId } })`\n * with `orgId` in component state. The cache is keyed by the *resolved* path,\n * so the way out is to seed that path directly.\n */\nfunction resolveSeedPath(\n key: string,\n rawPath: string,\n params: Record<string, string>,\n): string {\n const missing = requiredParamsOf(rawPath).filter(\n (name) => params[name] === undefined,\n );\n if (missing.length > 0) {\n throw new Error(\n `[gemi] <Page> cannot resolve the queryData key \"${key}\": no value for ` +\n `${missing.map((name) => `:${name}`).join(\", \")}. The query cache is ` +\n `keyed by the resolved path, so either add ${missing\n .map((name) => `\\`${name}\\``)\n .join(\", \")} to the page's \\`params\\`, or seed the resolved path ` +\n `(e.g. \"${applyParams(\n rawPath,\n Object.fromEntries(missing.map((name) => [name, \"123\"])),\n )}\") — which is what a query taking its params from component state ` +\n `reads under.`,\n );\n }\n return applyParams(rawPath, params);\n}\n\n/**\n * `queryData` in the shape the query cache seeds from: `{ [path]: { [variant]:\n * data } }`, the same payload `Query.prefetch` puts on a server-rendered page.\n */\nfunction toPrefetchedData(\n queryData: Record<string, unknown>,\n params: Record<string, string>,\n warned: Set<string>,\n) {\n const prefetchedData: Record<string, Record<string, unknown>> = {};\n for (const [key, data] of Object.entries(queryData)) {\n const [rawPath, rawSearch = \"\"] = key.split(\"?\");\n // Once per key per page, not once per render: this runs again on every\n // re-render, and a duplicated warning reads as a second mistake.\n if (rawPath.startsWith(\"/api/\") && !warned.has(key)) {\n warned.add(key);\n console.warn(\n `[gemi] queryData key \"${key}\" starts with /api. useQuery is keyed by ` +\n `the path you pass it — the /api prefix is added when it fetches — ` +\n `so this entry seeds a path no query reads.`,\n );\n }\n const path = resolveSeedPath(key, rawPath, params);\n prefetchedData[path] = {\n ...prefetchedData[path],\n [toVariantKey(rawSearch)]: data,\n };\n }\n return prefetchedData;\n}\n\n/**\n * `{ [locale]: { [dictionary]: { [key]: string } } }` — the payload\n * `Translator` serializes onto the page, which is the only form the client\n * ever sees. `dictionaries` are transposed into it (they are keyed the other\n * way round, by key then locale); `translations` is already in it and lands\n * under `locale`, last, so it wins.\n */\nfunction toClientDictionary(\n dictionaries: PageDictionary[],\n locales: string[],\n locale: string,\n translations: Record<string, Record<string, string>>,\n): Record<string, Record<string, Record<string, string>>> {\n const clientDictionary: Record<\n string,\n Record<string, Record<string, string>>\n > = {};\n for (const supported of locales) {\n clientDictionary[supported] ??= {};\n }\n for (const { name, dictionary } of dictionaries) {\n for (const [key, byLocale] of Object.entries(dictionary ?? {})) {\n for (const [dictLocale, translation] of Object.entries(byLocale ?? {})) {\n clientDictionary[dictLocale] ??= {};\n clientDictionary[dictLocale][name] ??= {};\n clientDictionary[dictLocale][name][key] = translation;\n }\n }\n }\n for (const [name, keys] of Object.entries(translations)) {\n clientDictionary[locale] ??= {};\n clientDictionary[locale][name] = {\n ...clientDictionary[locale][name],\n ...keys,\n };\n }\n return clientDictionary;\n}\n\n/**\n * Mounts a component with the inputs a view normally arrives with — route\n * params, the current locale and its dictionaries, prefetched query data, the\n * signed-in user — so a unit test can assert what it renders rather than only\n * its empty state.\n *\n * ```tsx\n * import { render, screen } from \"@testing-library/react\";\n * import { Page } from \"gemi/testing\";\n *\n * render(\n * <Page\n * pathname=\"/app/:orgId/chat\"\n * params={{ orgId: \"abc\" }}\n * queryData={{ \"/organizations/:orgId/messages\": [{ id: 1, body: \"hi\" }] }}\n * dictionaries={[ChatDictionary]}\n * >\n * <OrgChat />\n * </Page>,\n * );\n *\n * expect(screen.getByText(\"hi\")).toBeDefined();\n * ```\n *\n * It is a plain component, so it composes with any renderer — Testing\n * Library's `render`, `react-test-renderer`, or `renderToString`.\n *\n * Two things it does not do.\n *\n * It does not route: there is no view tree and no route manifest behind it, so\n * a navigation is reported through `onNavigate` rather than resolved. To\n * assert the page *after* one, render a second `<Page>`.\n *\n * And under `renderToString` it renders the server's own no-data branch for an\n * *unseeded* query rather than suspending — deliberately, because suspending\n * there is the streaming server's job: `createRoot` threads the request's\n * `ServerQueryStore` through `ServerQueryContext`, and that store is what runs\n * the route handler. A harness has no request and no handler to run, so there\n * is nothing to suspend on. Seeded queries render their data on the server\n * exactly as they do in the browser; unseeded ones behave as a route with no\n * `Query.prefetch` does, framework warning included.\n */\nexport const Page = (props: PropsWithChildren<PageProps>) => {\n const {\n children,\n pathname: routePath = \"/\",\n params = {},\n searchParams,\n hash = \"\",\n locale = \"en-US\",\n defaultLocale = locale,\n dictionaries = [],\n translations = {},\n queryData = {},\n queryConfig,\n user = null,\n breadcrumbs = [],\n features = {},\n theme,\n fallback = null,\n errorFallback,\n onNavigate,\n } = props;\n\n // The caller's list, in the caller's order — a language switcher maps over\n // it, so hoisting the page's own locale to the front would reorder the\n // rendered options. The defaults are appended, only to guarantee presence.\n const supportedLocales = useMemo(\n () =>\n Array.from(\n new Set([...(props.supportedLocales ?? []), defaultLocale, locale]),\n ),\n [props.supportedLocales, defaultLocale, locale],\n );\n const pathname = applyParams(routePath, params) || \"/\";\n const search = toSearchString(searchParams);\n // The URL's locale segment, which the default locale does not get.\n const urlLocaleSegment = locale === defaultLocale ? null : locale;\n\n const dictionary = useMemo(\n () =>\n toClientDictionary(dictionaries, supportedLocales, locale, translations),\n [dictionaries, supportedLocales, locale, translations],\n );\n\n // Keys that have already drawn a warning, so a re-render does not repeat it.\n const warnedRef = useRef<Set<string>>(new Set());\n const prefetchedData = useMemo(() => {\n const seeded = toPrefetchedData(queryData, params, warnedRef.current);\n // `useUser` reads `/auth/me` like any other query. Seeding it — with `null`\n // for the anonymous default — is what keeps a component test off the\n // network for a hook nothing in the test asked for.\n seeded[\"/auth/me\"] ??= { \"\": user ?? null };\n return seeded;\n }, [queryData, params, user]);\n\n // Keyed by `${view}:${pathname}` in the router's cache; the view names are\n // ours to choose here, and only their order reaches `useBreadcrumbs`.\n const { breadcrumbViews, breadcrumbsCache, breadcrumbsRecord } =\n useMemo(() => {\n const views = breadcrumbs.map((_, index) => `breadcrumb-${index}`);\n const cache = new Map<string, Breadcrumb>(\n breadcrumbs.map((breadcrumb, index) => [\n `${views[index]}:${pathname}`,\n breadcrumb,\n ]),\n );\n return {\n breadcrumbViews: views,\n breadcrumbsCache: cache,\n breadcrumbsRecord: Object.fromEntries(cache),\n };\n }, [breadcrumbs, pathname]);\n\n const [isNavigatingSubject] = useState(() => new Subject<boolean>(false));\n const [progressManager] = useState(\n () => new ProgressManager(isNavigatingSubject),\n );\n const [history] = useState(() =>\n createMemoryHistory({ initialEntries: [`${pathname}${search}${hash}`] }),\n );\n\n const routeState: RouteState & PageData = useMemo(\n () => ({\n views: [],\n params,\n search,\n state: {},\n pathname,\n hash,\n action: null,\n routePath,\n locale: urlLocaleSegment,\n data: {},\n i18n: { currentLocale: locale, dictionary, supportedLocales },\n prefetchedData,\n breadcrumbs: breadcrumbsRecord,\n features: features as Record<string, boolean>,\n appId: \"test\",\n }),\n [\n params,\n search,\n pathname,\n hash,\n routePath,\n urlLocaleSegment,\n locale,\n dictionary,\n supportedLocales,\n prefetchedData,\n breadcrumbsRecord,\n features,\n ],\n );\n\n const [routerSubject] = useState(() => new Subject<RouteState>(routeState));\n\n const onNavigateRef = useRef(onNavigate);\n onNavigateRef.current = onNavigate;\n // Subscribed during render, not in an effect, because React flushes child\n // effects before parent ones — and mount-time navigation is a real pattern:\n // `<Redirect>` pushes from its own mount effect, as does any auth guard. A\n // listener registered in `<Page>`'s effect is not there yet when those fire,\n // so `onNavigate` would silently miss exactly the redirects a test most\n // wants to assert. Ref-guarded, so React's double-invoke registers one.\n //\n // Nothing unsubscribes: this history is created per `<Page>` and reachable\n // from nowhere else, so it is collected with the page it belongs to.\n const listeningRef = useRef(false);\n if (!listeningRef.current) {\n listeningRef.current = true;\n history.listen(({ location, action }) => {\n const href = `${location.pathname}${location.search}${location.hash}`;\n onNavigateRef.current?.(\n href,\n action === Action.Replace ? \"replace\" : \"push\",\n );\n });\n }\n\n const serverData: ServerDataContextValue = useMemo(\n () => ({\n routeManifest: {},\n pageData: {},\n breadcrumbs: breadcrumbsRecord,\n prefetchedData,\n router: {\n pathname,\n params,\n currentPath: routePath,\n is404: false,\n searchParams: search,\n urlLocaleSegment,\n },\n i18n: {\n dictionary,\n currentLocale: locale,\n supportedLocales,\n defaultLocale,\n },\n componentTree: [],\n auth: { user: user as User },\n features: features as Record<string, boolean>,\n __csrf: \"test-csrf-token\",\n cssManifest: {},\n modulePreloadManifest: {},\n meta: {},\n appId: \"test\",\n }),\n [\n breadcrumbsRecord,\n prefetchedData,\n pathname,\n params,\n routePath,\n search,\n urlLocaleSegment,\n dictionary,\n locale,\n supportedLocales,\n defaultLocale,\n user,\n features,\n ],\n );\n\n // Everything the router hooks read, inert: nothing here fetches, preloads or\n // resolves a route, but every hook finds the shape it expects rather than an\n // empty context to crash on.\n const [viewEntriesSubject] = useState(() => new Subject<string[]>([]));\n const routerContext = useMemo(\n () => ({\n viewEntriesSubject,\n history,\n updatePageData: () => {},\n getPageData: () => ({}),\n getScrollPosition: () => 0,\n getViewPathsFromPathname: () => breadcrumbViews,\n getRoutePathnameFromHref: (href: string) => href,\n isNavigatingSubject,\n setNavigationAbortController: () => {},\n progressManager,\n fetchRouteCSS: async () => {},\n preloadRouteModules: () => {},\n prefetchRoute: async () => {},\n takePrefetched: () => null,\n clearPrefetchCache: () => {},\n breadcrumbsCache,\n routerSubject,\n urlLocaleSegment,\n }),\n [\n viewEntriesSubject,\n history,\n breadcrumbViews,\n isNavigatingSubject,\n progressManager,\n breadcrumbsCache,\n routerSubject,\n urlLocaleSegment,\n ],\n );\n\n const [websocket] = useState(() => ({\n subscribe: async () => {},\n unsubscribe: async () => {},\n broadcast: () => {},\n }));\n\n return (\n <ThemeProvider theme={theme}>\n <ServerDataContext.Provider value={serverData}>\n <I18nProvider>\n <WebSocketContext.Provider value={websocket}>\n <QueryManagerProvider queryConfig={queryConfig}>\n <ClientRouterContext.Provider value={routerContext}>\n <RouteTransitionProvider\n isPending={false}\n isFetching={false}\n transitionPath={[\"\", pathname]}\n >\n <RouteStateProvider state={routeState}>\n <Boundary\n errorFallback={errorFallback}\n fallback={fallback}\n resetKey={pathname}\n >\n {children}\n </Boundary>\n </RouteStateProvider>\n </RouteTransitionProvider>\n </ClientRouterContext.Provider>\n </QueryManagerProvider>\n </WebSocketContext.Provider>\n </I18nProvider>\n </ServerDataContext.Provider>\n </ThemeProvider>\n );\n};\n\n/**\n * The `Suspense` boundary every view renders inside, and — when the test\n * supplies a fallback for it — the error boundary too.\n *\n * `onReset={clearErrors}` is the part that has to be here rather than inline\n * above: it needs `QueryManagerContext`, and it is what makes a view's real\n * `Error` export testable. A fallback whose \"Try again\" calls\n * `resetErrorBoundary()` re-renders the child, and `useQuery` reads the\n * failure still stored on the `QueryResource` and throws it straight back —\n * so without this the fallback never goes away and the retry path that works\n * in the app cannot be exercised. `ClientRouter`'s own boundary wires exactly\n * this pair.\n */\nconst Boundary = (\n props: PropsWithChildren<{\n errorFallback?: ComponentType<FallbackProps>;\n fallback: ReactNode;\n resetKey: string;\n }>,\n) => {\n const { errorFallback: ErrorFallback, fallback, resetKey, children } = props;\n const { clearErrors } = useContext(QueryManagerContext);\n const content = <Suspense fallback={fallback}>{children}</Suspense>;\n\n if (!ErrorFallback) {\n return content;\n }\n\n return (\n <ErrorBoundary\n FallbackComponent={ErrorFallback}\n resetKeys={[resetKey]}\n onReset={clearErrors}\n >\n {content}\n </ErrorBoundary>\n );\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../testing/Page.tsx"],"sourcesContent":["import {\n Suspense,\n useContext,\n useMemo,\n useRef,\n useState,\n type ComponentType,\n type PropsWithChildren,\n type ReactNode,\n} from \"react\";\nimport { ErrorBoundary, type FallbackProps } from \"react-error-boundary\";\nimport { Action, createMemoryHistory } from \"history\";\n\nimport type { User } from \"../auth/types\";\nimport { ClientRouterContext } from \"../client/ClientRouterContext\";\nimport { I18nProvider } from \"../client/I18nContext\";\nimport { ProgressManager } from \"../client/ProgressManager\";\nimport {\n QueryManagerContext,\n QueryManagerProvider,\n type QueryConfig,\n} from \"../client/QueryManagerContext\";\nimport {\n RouteStateProvider,\n type PageData,\n type RouteState,\n} from \"../client/RouteStateContext\";\nimport { RouteTransitionProvider } from \"../client/RouteTransitionProvider\";\nimport {\n ServerDataContext,\n type ServerDataContextValue,\n} from \"../client/ServerDataProvider\";\nimport { ThemeProvider } from \"../client/ThemeProvider\";\nimport type { ClientFeatureKey } from \"../client/rpc\";\nimport type { Breadcrumb } from \"../client/useBreadcrumbs\";\nimport { WebSocketContext } from \"../client/WebsocketContext\";\nimport { applyParams } from \"../utils/applyParams\";\nimport { Subject } from \"../utils/Subject\";\nimport { toVariantKey } from \"../utils/variantKey\";\n\n/**\n * A dictionary as `Dictionary.create` produces it: a name and a\n * `{ key: { locale: string } }` map.\n *\n * Typed structurally rather than as `Dictionary<T>` for two reasons. It keeps\n * `gemi/testing` from importing `gemi/i18n` — a server entry, which reaches the\n * `Lang` facade and through it the container. And it means an object literal of\n * the same shape is accepted, so a test never *has* to import the app's\n * dictionaries at all.\n *\n * Only `name` and `dictionary` are read — both plain data. `Dictionary`'s\n * server-only methods (`render`, `reference`, which throw once `window` is\n * defined) are never called from here.\n */\nexport interface PageDictionary {\n name: string;\n dictionary: Record<string, Record<string, string>>;\n}\n\nexport interface PageProps {\n /**\n * The URL the view is mounted at. A template (`/app/:orgId/chat`) is resolved\n * against `params`, so the same string can be pasted from the router.\n */\n pathname?: string;\n /** Route params, as the router would have parsed them out of `pathname`. */\n params?: Record<string, string>;\n /** `\"?tab=recent\"`, `\"tab=recent\"`, or `{ tab: \"recent\" }`. */\n searchParams?: string | Record<string, string | number | boolean>;\n hash?: string;\n /** The locale `useTranslator` and `useLocale` report. */\n locale?: string;\n /**\n * The app's default locale. Links and `useNavigate` omit the locale segment\n * for it, so leaving this equal to `locale` (the default) keeps hrefs\n * unprefixed — set it to seed a page being viewed in a non-default locale.\n */\n defaultLocale?: string;\n supportedLocales?: string[];\n /**\n * Dictionaries the components under test translate against — the app's own,\n * or literals of the same shape.\n *\n * Note what importing the app's costs: `app/i18n/index.ts` imports\n * `gemi/i18n`, a server entry, so the test file pulls the container and\n * `node:async_hooks` into its module graph. Nothing there runs (and nothing\n * here calls it), so it is fine under any runner that has Node builtins —\n * vitest, `bun test` — and not under one that renders in a real browser. Use\n * `translations` there, which is the shape the client actually receives.\n */\n dictionaries?: PageDictionary[];\n /**\n * Translations for the current locale, already resolved: dictionary name →\n * key → string. This is the shape the server serializes onto the page, so it\n * is what the client reads at runtime — and it imports nothing.\n *\n * ```tsx\n * <Page translations={{ Chat: { greeting: \"Hello {{name}}\" } }}>\n * ```\n *\n * Merged over `dictionaries`, so the two compose: seed from the app's real\n * dictionary and override the one key a test is about.\n */\n translations?: Record<string, Record<string, string>>;\n /**\n * Data `useQuery` finds already cached, keyed by API path — the path passed\n * to `useQuery`, without the `/api` prefix the client adds when it fetches.\n * A key may carry a query string (`\"/lists?page=2\"`) to seed one search\n * variant.\n *\n * The cache is keyed by the *resolved* path, so a key's `:params` are\n * resolved against the page's `params` — and a key naming one the page does\n * not carry is an error, naming the key. That case is common enough to be\n * worth the loud failure: a query often takes its params from the call site\n * rather than the route (`useQuery(\"/orgs/:orgId/lists\", { params: { orgId } })`\n * with `orgId` in component state), and the right seed for it is the resolved\n * path, `\"/orgs/abc/lists\"`.\n *\n * Seeded at mount only: a component that mutates or refetches its data owns\n * the cache from then on.\n */\n queryData?: Record<string, unknown>;\n /** App-wide `useQuery` defaults, as `createRoot` threads them. */\n queryConfig?: QueryConfig;\n /**\n * What `useUser()` reports. Defaults to `null` — an anonymous visitor — and\n * either way the hook resolves from here without issuing a request.\n */\n user?: Partial<User> | null;\n /** What `useBreadcrumbs()` returns, in order. */\n breadcrumbs?: Breadcrumb[];\n /**\n * What `useFeature()` reports, keyed by feature key. Anything left out reads\n * as off, exactly as an undeclared key does in a real request.\n *\n * A test seeds the *outcome*, not the declaration: `app/features` owns the\n * targeting and the rollout, and re-deriving those here would mean building a\n * request context — a user, a `session_id`, a bot verdict — to assert a\n * branch that only cares which way the feature came out. Cover the targeting\n * where it lives, with a server test.\n */\n features?: Partial<Record<ClientFeatureKey, boolean>>;\n /**\n * The theme `useTheme()` starts on. The app reads a visitor's stored choice\n * out of `localStorage`; a test has no session to have made one in, so this\n * seeds it without writing to storage. `setTheme` works from either.\n */\n theme?: \"light\" | \"dark\" | \"system\";\n /**\n * Fallback for the `Suspense` boundary wrapped around the children — the\n * stand-in for the view's own `Loading` export, which the real router\n * supplies. Defaults to `null`.\n */\n fallback?: ReactNode;\n /**\n * Renders in place of the children when one throws, mirroring a view's\n * `Error` export. Without it a thrown render (an errored suspense query,\n * say) propagates out of `render()` and fails the test, which is usually\n * what you want.\n */\n errorFallback?: ComponentType<FallbackProps>;\n /**\n * Called when something navigates — a `Link` click, `useNavigate().push`.\n * The navigation is reported, not performed: `<Page>` renders one route and\n * does not re-resolve the tree, so `useLocation()` keeps reporting the\n * pathname it was given.\n */\n onNavigate?: (href: string, action: \"push\" | \"replace\") => void;\n}\n\n/** `\"?a=b\"` (or `\"\"`) from any of the shapes `searchParams` accepts. */\nfunction toSearchString(search: PageProps[\"searchParams\"]): string {\n if (!search) return \"\";\n const query =\n typeof search === \"string\"\n ? search.replace(/^\\?/, \"\")\n : new URLSearchParams(\n Object.entries(search).map(([key, value]) => [key, String(value)]),\n ).toString();\n return query ? `?${query}` : \"\";\n}\n\n/**\n * The `:params` a seed key needs supplied. Wildcards (`:rest*`) are excluded —\n * `applyParams` drops those when missing, which is a resolution rather than a\n * failure.\n *\n * Optional params (`:id?`) cannot reach here at all: a key is split at its\n * first `?` to separate the search string, so `\"/lists/:folderId?\"` arrives as\n * the path `/lists/:folderId`. Seed the resolved path for those.\n */\nfunction requiredParamsOf(path: string): string[] {\n return Array.from(path.matchAll(/:([^/]+)/g), ([, name]) => name).filter(\n (name) => !name.endsWith(\"*\"),\n );\n}\n\n/**\n * Fails a `queryData` key whose `:params` the page does not carry, saying so\n * in terms of the key.\n *\n * Without this the two documented runners disagree, and neither is legible.\n * `applyParams` throws `Missing parameter: orgId` under `import.meta.env.DEV`\n * (vitest) — a router-utility stack trace out of `<Page>`'s render, before\n * anything mounts, naming nothing that appears in the test. Under `bun test`\n * that flag is falsy, so it logs and seeds the literal `/orgs/undefined/lists`\n * instead: the seed misses, the query goes to the network, and the assertion\n * runs against a loading state.\n *\n * The case is common because a query's params often come from the call site\n * rather than the route — `useQuery(\"/orgs/:orgId/lists\", { params: { orgId } })`\n * with `orgId` in component state. The cache is keyed by the *resolved* path,\n * so the way out is to seed that path directly.\n */\nfunction resolveSeedPath(\n key: string,\n rawPath: string,\n params: Record<string, string>,\n): string {\n const missing = requiredParamsOf(rawPath).filter(\n (name) => params[name] === undefined,\n );\n if (missing.length > 0) {\n throw new Error(\n `[gemi] <Page> cannot resolve the queryData key \"${key}\": no value for ` +\n `${missing.map((name) => `:${name}`).join(\", \")}. The query cache is ` +\n `keyed by the resolved path, so either add ${missing\n .map((name) => `\\`${name}\\``)\n .join(\", \")} to the page's \\`params\\`, or seed the resolved path ` +\n `(e.g. \"${applyParams(\n rawPath,\n Object.fromEntries(missing.map((name) => [name, \"123\"])),\n )}\") — which is what a query taking its params from component state ` +\n `reads under.`,\n );\n }\n return applyParams(rawPath, params);\n}\n\n/**\n * `queryData` in the shape the query cache seeds from: `{ [path]: { [variant]:\n * data } }`, the same payload `Query.prefetch` puts on a server-rendered page.\n */\nfunction toPrefetchedData(\n queryData: Record<string, unknown>,\n params: Record<string, string>,\n warned: Set<string>,\n) {\n const prefetchedData: Record<string, Record<string, unknown>> = {};\n for (const [key, data] of Object.entries(queryData)) {\n const [rawPath, rawSearch = \"\"] = key.split(\"?\");\n // Once per key per page, not once per render: this runs again on every\n // re-render, and a duplicated warning reads as a second mistake.\n if (rawPath.startsWith(\"/api/\") && !warned.has(key)) {\n warned.add(key);\n console.warn(\n `[gemi] queryData key \"${key}\" starts with /api. useQuery is keyed by ` +\n `the path you pass it — the /api prefix is added when it fetches — ` +\n `so this entry seeds a path no query reads.`,\n );\n }\n const path = resolveSeedPath(key, rawPath, params);\n prefetchedData[path] = {\n ...prefetchedData[path],\n [toVariantKey(rawSearch)]: data,\n };\n }\n return prefetchedData;\n}\n\n/**\n * `{ [locale]: { [dictionary]: { [key]: string } } }` — the payload\n * `Translator` serializes onto the page, which is the only form the client\n * ever sees. `dictionaries` are transposed into it (they are keyed the other\n * way round, by key then locale); `translations` is already in it and lands\n * under `locale`, last, so it wins.\n */\nfunction toClientDictionary(\n dictionaries: PageDictionary[],\n locales: string[],\n locale: string,\n translations: Record<string, Record<string, string>>,\n): Record<string, Record<string, Record<string, string>>> {\n const clientDictionary: Record<\n string,\n Record<string, Record<string, string>>\n > = {};\n for (const supported of locales) {\n clientDictionary[supported] ??= {};\n }\n for (const { name, dictionary } of dictionaries) {\n for (const [key, byLocale] of Object.entries(dictionary ?? {})) {\n for (const [dictLocale, translation] of Object.entries(byLocale ?? {})) {\n clientDictionary[dictLocale] ??= {};\n clientDictionary[dictLocale][name] ??= {};\n clientDictionary[dictLocale][name][key] = translation;\n }\n }\n }\n for (const [name, keys] of Object.entries(translations)) {\n clientDictionary[locale] ??= {};\n clientDictionary[locale][name] = {\n ...clientDictionary[locale][name],\n ...keys,\n };\n }\n return clientDictionary;\n}\n\n/**\n * Mounts a component with the inputs a view normally arrives with — route\n * params, the current locale and its dictionaries, prefetched query data, the\n * signed-in user — so a unit test can assert what it renders rather than only\n * its empty state.\n *\n * ```tsx\n * import { render, screen } from \"@testing-library/react\";\n * import { Page } from \"gemi/testing\";\n *\n * render(\n * <Page\n * pathname=\"/app/:orgId/chat\"\n * params={{ orgId: \"abc\" }}\n * queryData={{ \"/organizations/:orgId/messages\": [{ id: 1, body: \"hi\" }] }}\n * dictionaries={[ChatDictionary]}\n * >\n * <OrgChat />\n * </Page>,\n * );\n *\n * expect(screen.getByText(\"hi\")).toBeDefined();\n * ```\n *\n * It is a plain component, so it composes with any renderer — Testing\n * Library's `render`, `react-test-renderer`, or `renderToString`.\n *\n * Two things it does not do.\n *\n * It does not route: there is no view tree and no route manifest behind it, so\n * a navigation is reported through `onNavigate` rather than resolved. To\n * assert the page *after* one, render a second `<Page>`.\n *\n * And under `renderToString` it renders the server's own no-data branch for an\n * *unseeded* query rather than suspending — deliberately, because suspending\n * there is the streaming server's job: `createRoot` threads the request's\n * `ServerQueryStore` through `ServerQueryContext`, and that store is what runs\n * the route handler. A harness has no request and no handler to run, so there\n * is nothing to suspend on. Seeded queries render their data on the server\n * exactly as they do in the browser; unseeded ones behave as a route with no\n * `Query.prefetch` does, framework warning included.\n */\nexport const Page = (props: PropsWithChildren<PageProps>) => {\n const {\n children,\n pathname: routePath = \"/\",\n params = {},\n searchParams,\n hash = \"\",\n locale = \"en-US\",\n defaultLocale = locale,\n dictionaries = [],\n translations = {},\n queryData = {},\n queryConfig,\n user = null,\n breadcrumbs = [],\n features = {},\n theme,\n fallback = null,\n errorFallback,\n onNavigate,\n } = props;\n\n // The caller's list, in the caller's order — a language switcher maps over\n // it, so hoisting the page's own locale to the front would reorder the\n // rendered options. The defaults are appended, only to guarantee presence.\n const supportedLocales = useMemo(\n () =>\n Array.from(\n new Set([...(props.supportedLocales ?? []), defaultLocale, locale]),\n ),\n [props.supportedLocales, defaultLocale, locale],\n );\n const pathname = applyParams(routePath, params) || \"/\";\n const search = toSearchString(searchParams);\n // The URL's locale segment, which the default locale does not get.\n const urlLocaleSegment = locale === defaultLocale ? null : locale;\n\n const dictionary = useMemo(\n () =>\n toClientDictionary(dictionaries, supportedLocales, locale, translations),\n [dictionaries, supportedLocales, locale, translations],\n );\n\n // Keys that have already drawn a warning, so a re-render does not repeat it.\n const warnedRef = useRef<Set<string>>(new Set());\n const prefetchedData = useMemo(() => {\n const seeded = toPrefetchedData(queryData, params, warnedRef.current);\n // `useUser` reads `/auth/me` like any other query. Seeding it — with `null`\n // for the anonymous default — is what keeps a component test off the\n // network for a hook nothing in the test asked for.\n seeded[\"/auth/me\"] ??= { \"\": user ?? null };\n return seeded;\n }, [queryData, params, user]);\n\n // Keyed by `${view}:${pathname}` in the router's cache; the view names are\n // ours to choose here, and only their order reaches `useBreadcrumbs`.\n const { breadcrumbViews, breadcrumbsCache, breadcrumbsRecord } =\n useMemo(() => {\n const views = breadcrumbs.map((_, index) => `breadcrumb-${index}`);\n const cache = new Map<string, Breadcrumb>(\n breadcrumbs.map((breadcrumb, index) => [\n `${views[index]}:${pathname}`,\n breadcrumb,\n ]),\n );\n return {\n breadcrumbViews: views,\n breadcrumbsCache: cache,\n breadcrumbsRecord: Object.fromEntries(cache),\n };\n }, [breadcrumbs, pathname]);\n\n const [isNavigatingSubject] = useState(() => new Subject<boolean>(false));\n const [progressManager] = useState(\n () => new ProgressManager(isNavigatingSubject),\n );\n const [history] = useState(() =>\n createMemoryHistory({ initialEntries: [`${pathname}${search}${hash}`] }),\n );\n\n const routeState: RouteState & PageData = useMemo(\n () => ({\n views: [],\n params,\n search,\n state: {},\n pathname,\n hash,\n action: null,\n routePath,\n locale: urlLocaleSegment,\n data: {},\n i18n: { currentLocale: locale, dictionary, supportedLocales },\n prefetchedData,\n breadcrumbs: breadcrumbsRecord,\n features: features as Record<string, boolean>,\n appId: \"test\",\n }),\n [\n params,\n search,\n pathname,\n hash,\n routePath,\n urlLocaleSegment,\n locale,\n dictionary,\n supportedLocales,\n prefetchedData,\n breadcrumbsRecord,\n features,\n ],\n );\n\n const [routerSubject] = useState(() => new Subject<RouteState>(routeState));\n\n const onNavigateRef = useRef(onNavigate);\n onNavigateRef.current = onNavigate;\n // Subscribed during render, not in an effect, because React flushes child\n // effects before parent ones — and mount-time navigation is a real pattern:\n // `<Redirect>` pushes from its own mount effect, as does any auth guard. A\n // listener registered in `<Page>`'s effect is not there yet when those fire,\n // so `onNavigate` would silently miss exactly the redirects a test most\n // wants to assert. Ref-guarded, so React's double-invoke registers one.\n //\n // Nothing unsubscribes: this history is created per `<Page>` and reachable\n // from nowhere else, so it is collected with the page it belongs to.\n const listeningRef = useRef(false);\n if (!listeningRef.current) {\n listeningRef.current = true;\n history.listen(({ location, action }) => {\n const href = `${location.pathname}${location.search}${location.hash}`;\n onNavigateRef.current?.(\n href,\n action === Action.Replace ? \"replace\" : \"push\",\n );\n });\n }\n\n const serverData: ServerDataContextValue = useMemo(\n () => ({\n routeManifest: {},\n pageData: {},\n breadcrumbs: breadcrumbsRecord,\n prefetchedData,\n router: {\n pathname,\n params,\n currentPath: routePath,\n is404: false,\n searchParams: search,\n urlLocaleSegment,\n },\n i18n: {\n dictionary,\n currentLocale: locale,\n supportedLocales,\n defaultLocale,\n },\n componentTree: [],\n auth: { user: user as User },\n features: features as Record<string, boolean>,\n __csrf: \"test-csrf-token\",\n cssManifest: {},\n modulePreloadManifest: {},\n meta: {},\n appId: \"test\",\n }),\n [\n breadcrumbsRecord,\n prefetchedData,\n pathname,\n params,\n routePath,\n search,\n urlLocaleSegment,\n dictionary,\n locale,\n supportedLocales,\n defaultLocale,\n user,\n features,\n ],\n );\n\n // Everything the router hooks read, inert: nothing here fetches, preloads or\n // resolves a route, but every hook finds the shape it expects rather than an\n // empty context to crash on.\n const [viewEntriesSubject] = useState(() => new Subject<string[]>([]));\n const routerContext = useMemo(\n () => ({\n viewEntriesSubject,\n history,\n updatePageData: () => {},\n getPageData: () => ({}),\n getScrollPosition: () => 0,\n getViewPathsFromPathname: () => breadcrumbViews,\n getRoutePathnameFromHref: (href: string) => href,\n isNavigatingSubject,\n setNavigationAbortController: () => {},\n progressManager,\n fetchRouteCSS: async () => {},\n preloadRouteModules: () => {},\n prefetchRoute: async () => {},\n takePrefetched: () => null,\n clearPrefetchCache: () => {},\n breadcrumbsCache,\n routerSubject,\n urlLocaleSegment,\n }),\n [\n viewEntriesSubject,\n history,\n breadcrumbViews,\n isNavigatingSubject,\n progressManager,\n breadcrumbsCache,\n routerSubject,\n urlLocaleSegment,\n ],\n );\n\n const [websocket] = useState(() => ({\n subscribe: async () => {},\n unsubscribe: async () => {},\n broadcast: () => {},\n }));\n\n return (\n <ThemeProvider theme={theme}>\n <ServerDataContext.Provider value={serverData}>\n <I18nProvider>\n <WebSocketContext.Provider value={websocket}>\n <QueryManagerProvider queryConfig={queryConfig}>\n <ClientRouterContext.Provider value={routerContext}>\n <RouteTransitionProvider\n isPending={false}\n isFetching={false}\n transitionPath={[\"\", pathname]}\n >\n <RouteStateProvider state={routeState}>\n <Boundary\n errorFallback={errorFallback}\n fallback={fallback}\n resetKey={pathname}\n >\n {children}\n </Boundary>\n </RouteStateProvider>\n </RouteTransitionProvider>\n </ClientRouterContext.Provider>\n </QueryManagerProvider>\n </WebSocketContext.Provider>\n </I18nProvider>\n </ServerDataContext.Provider>\n </ThemeProvider>\n );\n};\n\n/**\n * The `Suspense` boundary every view renders inside, and — when the test\n * supplies a fallback for it — the error boundary too.\n *\n * `onReset={clearErrors}` is the part that has to be here rather than inline\n * above: it needs `QueryManagerContext`, and it is what makes a view's real\n * `Error` export testable. A fallback whose \"Try again\" calls\n * `resetErrorBoundary()` re-renders the child, and `useQuery` reads the\n * failure still stored on the `QueryResource` and throws it straight back —\n * so without this the fallback never goes away and the retry path that works\n * in the app cannot be exercised. `ClientRouter`'s own boundary wires exactly\n * this pair.\n */\nconst Boundary = (\n props: PropsWithChildren<{\n errorFallback?: ComponentType<FallbackProps>;\n fallback: ReactNode;\n resetKey: string;\n }>,\n) => {\n const { errorFallback: ErrorFallback, fallback, resetKey, children } = props;\n const { clearErrors } = useContext(QueryManagerContext);\n const content = <Suspense fallback={fallback}>{children}</Suspense>;\n\n if (!ErrorFallback) {\n return content;\n }\n\n return (\n <ErrorBoundary\n FallbackComponent={ErrorFallback}\n resetKeys={[resetKey]}\n onReset={clearErrors}\n >\n {content}\n </ErrorBoundary>\n );\n};\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gemi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Enes Tufekci <enes@gemijs.dev>",
|
|
@@ -15,12 +15,15 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"dist/**/*",
|
|
18
|
-
"ide/typescript-plugin/package.json"
|
|
18
|
+
"ide/typescript-plugin/package.json",
|
|
19
|
+
"skills/**/*"
|
|
19
20
|
],
|
|
20
21
|
"module": true,
|
|
21
22
|
"exports": {
|
|
22
23
|
"./http": "./dist/http/index.js",
|
|
23
24
|
"./client": "./dist/client/index.js",
|
|
25
|
+
"./ai": "./dist/ai/index.js",
|
|
26
|
+
"./ai/client": "./dist/ai/client/index.js",
|
|
24
27
|
"./testing": "./dist/testing/index.js",
|
|
25
28
|
"./app": "./dist/app/index.js",
|
|
26
29
|
"./facades": "./dist/facades/index.js",
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gemi-react-best-practices
|
|
3
|
+
description: Best practices for the gemi framework (Bun + Vite + React 19 SSR full-stack TypeScript framework) — routing, controllers, the ORM, the SSR data payload, useQuery, i18n, jobs/services, and testing. Use when writing, reviewing, or refactoring anything under app/ — views, routers, controllers, models, dictionaries — or when a task mentions slow pages, extra round-trips, N+1 queries, bundle size, or re-renders.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# gemi Best Practices
|
|
7
|
+
|
|
8
|
+
Rules for building on **gemi**, the full-stack TypeScript framework this app runs on
|
|
9
|
+
(Bun + Vite, React 19 SSR). 45 rules across 11 categories, ordered by impact.
|
|
10
|
+
|
|
11
|
+
Every rule is derived from the gemi documentation and from patterns that recur across
|
|
12
|
+
gemi codebases. This skill ships with the `gemi` package, so it describes the
|
|
13
|
+
framework; where it and your app disagree, your app wins (ground rule 2).
|
|
14
|
+
|
|
15
|
+
## When to Apply
|
|
16
|
+
|
|
17
|
+
- Adding or changing a route, view, layout, controller, or middleware
|
|
18
|
+
- Writing a `useQuery` read, a mutation, or a `Query.prefetch`
|
|
19
|
+
- Writing ORM queries — especially list reads, aggregations, and transactions
|
|
20
|
+
- Adding a Job, Service, or one-off Command
|
|
21
|
+
- Writing dictionaries or tests
|
|
22
|
+
- Diagnosing extra round-trips, slow first paint, blank flashes, or bundle growth
|
|
23
|
+
|
|
24
|
+
## Ground Rules That Precede Everything Below
|
|
25
|
+
|
|
26
|
+
1. **Fetch the docs; do not guess an API.** Index (page list + one-line summaries):
|
|
27
|
+
<https://nstfkc.github.io/gemi/llms.txt> — fetch only the pages you need.
|
|
28
|
+
Everything in one file: <https://nstfkc.github.io/gemi/llms-full.txt> (~170 KB).
|
|
29
|
+
2. **`CLAUDE.md` in this app wins** where it and either the docs or this skill
|
|
30
|
+
disagree — it describes *our* app, not the framework in general.
|
|
31
|
+
3. **Mirror existing code.** Don't invent gemi patterns. When a rule here and an
|
|
32
|
+
existing pattern disagree, check git history before "fixing" it.
|
|
33
|
+
4. **Routing is class-based and data is loaded in controllers.** File location
|
|
34
|
+
registers nothing; a view's file name has no relation to its URL.
|
|
35
|
+
5. **Check whether the React Compiler is on before hand-memoizing.** It is enabled
|
|
36
|
+
per app in `gemi.config.ts` via the React plugin's `compiler` option, which the
|
|
37
|
+
gemi template turns on by default — and `GEMI_REACT_COMPILER=off` in the
|
|
38
|
+
environment disables it, so the config is not the last word. With it on, the client build is
|
|
39
|
+
auto-memoized and a hand-written `useMemo` is usually redundant; with it off,
|
|
40
|
+
memoization is manual and load-bearing. The compiler never runs on the SSR view
|
|
41
|
+
build — server rendering is a single pass — so it changes nothing about what a
|
|
42
|
+
controller does.
|
|
43
|
+
|
|
44
|
+
## Rule Categories by Priority
|
|
45
|
+
|
|
46
|
+
| Priority | Category | Impact | Prefix |
|
|
47
|
+
|----------|----------|--------|--------|
|
|
48
|
+
| 0 | Project Structure | HIGH | `structure-` |
|
|
49
|
+
| 1 | Server Payload & Waterfalls | CRITICAL | `payload-` |
|
|
50
|
+
| 2 | Client Data Fetching | CRITICAL | `query-` |
|
|
51
|
+
| 3 | Data Access (ORM) | HIGH | `orm-` |
|
|
52
|
+
| 4 | Routing & Middleware | HIGH | `routing-` |
|
|
53
|
+
| 5 | Controllers & Request Handling | HIGH | `controller-` |
|
|
54
|
+
| 6 | Services, Jobs & Commands | HIGH | `service-` |
|
|
55
|
+
| 7 | Client Components & Navigation | MEDIUM | `client-` |
|
|
56
|
+
| 8 | Bundle Size | MEDIUM | `bundle-` |
|
|
57
|
+
| 9 | Internationalization | MEDIUM | `i18n-` |
|
|
58
|
+
| 10 | Testing | MEDIUM | `testing-` |
|
|
59
|
+
|
|
60
|
+
## Project Structure
|
|
61
|
+
|
|
62
|
+
Everything the framework guarantees lives under `app/`. This is the layout a
|
|
63
|
+
scaffolded project has; the parts marked *discovered* are read by walking the
|
|
64
|
+
directory, so writing the file is the registration.
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
app/
|
|
68
|
+
server.ts server entry - boots the kernel, starts the HTTP server
|
|
69
|
+
client.tsx browser entry - hydrates the React app
|
|
70
|
+
preload.ts optional Bun preload, runs before the server starts
|
|
71
|
+
kernel/Kernel.ts declares `config`, `providers`, and `models`
|
|
72
|
+
config/ runtime config, one file per framework service
|
|
73
|
+
providers/ your own container bindings
|
|
74
|
+
http/
|
|
75
|
+
routes/ api.ts and view.ts - the root routers
|
|
76
|
+
controllers/ controller classes
|
|
77
|
+
requests/ HttpRequest subclasses used for validation
|
|
78
|
+
models/ ORM models (declared on the Kernel)
|
|
79
|
+
generated/ OUTPUT - never hand-edit
|
|
80
|
+
views/ React views, layouts, RootLayout (path is NOT the URL)
|
|
81
|
+
email/ jsx-email templates
|
|
82
|
+
i18n/ dictionaries
|
|
83
|
+
cron/ CronJob classes <- discovered at boot
|
|
84
|
+
jobs/ Job classes <- discovered at boot
|
|
85
|
+
listeners/ Listener classes <- discovered at boot
|
|
86
|
+
commands/ defineCommand chains <- discovered by `gemi run`
|
|
87
|
+
database/prisma.ts the Prisma client instance
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`app/listeners/` is absent from a fresh scaffold; that is not an error, it means
|
|
91
|
+
the app has no listeners yet.
|
|
92
|
+
|
|
93
|
+
### Where a new file goes
|
|
94
|
+
|
|
95
|
+
| Adding a… | Goes in | Reached by |
|
|
96
|
+
|---|---|---|
|
|
97
|
+
| page / layout | `app/views/` | a **router**, never its file path |
|
|
98
|
+
| URL, or middleware on one | `app/http/routes/` | mounted from `view.ts` / `api.ts` |
|
|
99
|
+
| handler logic for a route | `app/http/controllers/` | named by a router |
|
|
100
|
+
| request validation | `app/http/requests/` | named by a controller method |
|
|
101
|
+
| ORM model | `app/models/` | the Kernel's `models` |
|
|
102
|
+
| background work | `app/jobs/` | discovery |
|
|
103
|
+
| scheduled work | `app/cron/` | discovery |
|
|
104
|
+
| event handler | `app/listeners/` | discovery |
|
|
105
|
+
| one-off ops task | `app/commands/` | discovery, via `gemi run` |
|
|
106
|
+
| anything with I/O, a client, or state | `app/services/` | the container, by `static token` |
|
|
107
|
+
| container bindings | `app/providers/` | the Kernel's `providers` |
|
|
108
|
+
| settings for a framework service | `app/config/` | the Kernel's `config` |
|
|
109
|
+
| translations | `app/i18n/` | `defineDictionary` |
|
|
110
|
+
| build config (Vite/Bun plugins) | `gemi.config.ts` **at the root** | the CLI — *not* `app/config/` |
|
|
111
|
+
|
|
112
|
+
Two mistakes this table exists to prevent, both of which typecheck and then do
|
|
113
|
+
nothing: putting a view at a path that looks like its URL and expecting it to
|
|
114
|
+
route, and writing a model without declaring it on the Kernel. See
|
|
115
|
+
`structure-discovered-vs-registered` and `structure-do-not-reinvent-the-framework`.
|
|
116
|
+
|
|
117
|
+
## Quick Reference
|
|
118
|
+
|
|
119
|
+
### 0. Project Structure (HIGH)
|
|
120
|
+
|
|
121
|
+
- `structure-discovered-vs-registered` - Four directories register by being walked; models, routers, config and providers are declared
|
|
122
|
+
- `structure-do-not-reinvent-the-framework` - `lib/`, a singleton, a hand-rolled registry: gemi already has each of these with a name
|
|
123
|
+
|
|
124
|
+
### 1. Server Payload & Waterfalls (CRITICAL)
|
|
125
|
+
|
|
126
|
+
The biggest lever in the app. gemi ships a data payload with the SSR HTML; every read
|
|
127
|
+
the client discovers only after hydration costs a round-trip the payload could have
|
|
128
|
+
carried for free.
|
|
129
|
+
|
|
130
|
+
- `payload-prefetch-mirrors-usequery` - A prefetch lands only if path + params + search match the cache key exactly
|
|
131
|
+
- `payload-prefetch-late-queries` - Prefetch reads render discovers late (nested under suspense, conditionally rendered)
|
|
132
|
+
- `payload-instant-vs-prefetch` - `Query.instant` blocks the response; `Query.prefetch` runs in parallel
|
|
133
|
+
- `payload-parallel-controller-work` - `Promise.all` independent work in a controller method
|
|
134
|
+
- `payload-dont-overprefetch` - Popover and heavy-collection reads belong behind a mount gate
|
|
135
|
+
- `payload-minimal-view-props` - Ship the shape the view renders, not the row you loaded
|
|
136
|
+
|
|
137
|
+
### 2. Client Data Fetching (CRITICAL)
|
|
138
|
+
|
|
139
|
+
- `query-no-hand-rolled-fetch` - `useQuery` / mutation hooks, never a raw `fetch`
|
|
140
|
+
- `query-suspense-default` - `suspense: true` is the DEFAULT and throws to the nearest boundary
|
|
141
|
+
- `query-lazy-vs-mount-gate` - A lazy query does not refetch when its variant changes
|
|
142
|
+
- `query-share-cache-key` - Identical path + params + search dedupes across components for free
|
|
143
|
+
- `query-keep-previous-data` - Keep the previous page rendered while the next variant loads
|
|
144
|
+
- `query-mutate-over-refetch` - Write the cache with `mutate` / `useMutate` instead of refetching
|
|
145
|
+
- `query-debounce-search-variant` - Debounce a value before it becomes a query variant
|
|
146
|
+
- `query-revalidate-on-focus` - Opt in only for cross-tab-mutable data; let `staleTime` gate it
|
|
147
|
+
|
|
148
|
+
### 3. Data Access — ORM (HIGH)
|
|
149
|
+
|
|
150
|
+
- `orm-include-not-n-plus-one` - One `include` tree beats a loop of queries (lateral strategy = one round trip)
|
|
151
|
+
- `orm-transaction-sequential` - `Promise.all` inside `Model.transaction` is unsafe — one reserved connection
|
|
152
|
+
- `orm-transaction-no-io` - Keep network calls, uploads and queue pushes out of a transaction callback
|
|
153
|
+
- `orm-analytics-connection` - Heavy admin/cron aggregations run on the analytics pool
|
|
154
|
+
- `orm-paginate-helper` - Use `paginate()` from `gemi/orm`; know its 100-row ceiling
|
|
155
|
+
- `orm-select-narrow` - `select` the columns the response actually serializes
|
|
156
|
+
- `orm-plain-rows-by-default` - Plain rows are free; `track` costs ~100% on a large read
|
|
157
|
+
|
|
158
|
+
### 4. Routing & Middleware (HIGH)
|
|
159
|
+
|
|
160
|
+
- `routing-routers-are-classes` - Routes are declared on router classes, not by file location
|
|
161
|
+
- `routing-middleware-dsl` - Middleware is a string DSL at router or route level; `-name` cancels
|
|
162
|
+
- `routing-cache-policy-constants` - Name cache policies once and reuse the constant
|
|
163
|
+
- `routing-resource-routes` - `resource()` for standard REST, with per-method middleware
|
|
164
|
+
|
|
165
|
+
### 5. Controllers & Request Handling (HIGH)
|
|
166
|
+
|
|
167
|
+
- `controller-request-schema` - Validate with a request schema, not inline checks
|
|
168
|
+
- `controller-throw-framework-errors` - Throw `ValidationError` / auth errors; never invent a response shape
|
|
169
|
+
- `controller-redirect-facade-throws` - Never wrap the `Redirect` facade in try/catch — it works by throwing
|
|
170
|
+
- `controller-authorize-every-tenant-read` - Scope every tenant read, in middleware or an ORM policy
|
|
171
|
+
- `controller-parse-request-at-the-boundary` - Parse the request in the controller; keep utils framework-free
|
|
172
|
+
|
|
173
|
+
### 6. Services, Jobs & Commands (HIGH)
|
|
174
|
+
|
|
175
|
+
- `service-static-token-and-name` - `static token` / `static name` survive minification; class names do not
|
|
176
|
+
- `service-queue-is-in-memory` - The queue is in-process; enqueued work is lost on restart
|
|
177
|
+
- `service-lazy-not-module-scope` - Construct clients lazily — discovery imports every module
|
|
178
|
+
|
|
179
|
+
### 7. Client Components & Navigation (MEDIUM)
|
|
180
|
+
|
|
181
|
+
- `client-typed-links` - Navigate with a typed `Link` / `useNavigate`, not an interpolated path
|
|
182
|
+
- `client-form-vs-mutation-hooks` - `<Form>` first; mutation hooks when you need control
|
|
183
|
+
- `client-loading-error-exports` - A route module's `Loading` / `Error` exports ARE its suspense boundary
|
|
184
|
+
- `client-no-effect-data-flow` - Derive in `useMemo`, reset in the handler, debounce before use
|
|
185
|
+
|
|
186
|
+
### 8. Bundle Size (MEDIUM)
|
|
187
|
+
|
|
188
|
+
- `bundle-deep-imports` - Import UI primitives by deep path; a barrel drags the whole library in
|
|
189
|
+
- `bundle-mount-gate-heavy-panels` - Put heavy subtrees inside the thing that unmounts them
|
|
190
|
+
|
|
191
|
+
### 9. Internationalization (MEDIUM)
|
|
192
|
+
|
|
193
|
+
- `i18n-define-dictionary-inline` - The `defineDictionary` literal must be inline — a helper fails the BUILD
|
|
194
|
+
|
|
195
|
+
### 10. Testing (MEDIUM)
|
|
196
|
+
|
|
197
|
+
- `testing-page-seeds-real-inputs` - Mount with `<Page>` from `gemi/testing`; never mock `gemi/client`
|
|
198
|
+
- `testing-assert-behaviour-over-markup` - Query the DOM; don't scrape `renderToStaticMarkup` output
|
|
199
|
+
- `testing-match-the-suite` - Copy the runner and naming already in the directory; component tests need a DOM
|
|
200
|
+
|
|
201
|
+
## The Gotchas Most Likely to Bite
|
|
202
|
+
|
|
203
|
+
Counter-intuitive behaviours that produce silent failures rather than errors:
|
|
204
|
+
|
|
205
|
+
| Gotcha | Rule |
|
|
206
|
+
|---|---|
|
|
207
|
+
| `Redirect` works by **throwing** — a `try/catch` swallows it | `controller-redirect-facade-throws` |
|
|
208
|
+
| A `defineDictionary` behind a helper passes tests and fails the **build** | `i18n-define-dictionary-inline` |
|
|
209
|
+
| `Promise.all` inside a transaction shares one reserved connection | `orm-transaction-sequential` |
|
|
210
|
+
| A lazy query never refetches when its variant changes | `query-lazy-vs-mount-gate` |
|
|
211
|
+
| A prefetch whose `search` differs primes a slot nothing reads | `payload-prefetch-mirrors-usequery` |
|
|
212
|
+
| A class name is minified in prod — jobs and services need a static string | `service-static-token-and-name` |
|
|
213
|
+
| `paginate()` silently caps `perPage` at 100 | `orm-paginate-helper` |
|
|
214
|
+
| Command discovery **imports** every file under `app/commands` | `service-lazy-not-module-scope` |
|
|
215
|
+
| A suspending query blanks everything up to the nearest boundary | `query-suspense-default` |
|
|
216
|
+
| Returning `{ error }` from a controller is a **200** the client reads as success | `controller-throw-framework-errors` |
|
|
217
|
+
|
|
218
|
+
## How to Use
|
|
219
|
+
|
|
220
|
+
Read a rule file for the full explanation and examples:
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
rules/payload-prefetch-mirrors-usequery.md
|
|
224
|
+
rules/orm-transaction-sequential.md
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Each contains a short why, an incorrect example, a correct example, and — where one
|
|
228
|
+
exists — a link to the gemi documentation page that covers it.
|
|
229
|
+
|
|
230
|
+
`rules/_sections.md` holds the category metadata; `rules/_template.md` is the shape a
|
|
231
|
+
new rule follows.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Sections
|
|
2
|
+
|
|
3
|
+
The section ID (in parentheses) is the filename prefix used to group rules.
|
|
4
|
+
Rules are sorted by title within a section. Files starting with `_` are not rules.
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. Server Payload & Waterfalls (payload)
|
|
9
|
+
|
|
10
|
+
**Impact:** CRITICAL
|
|
11
|
+
**Description:** gemi renders on the server and ships a data payload with the HTML. Every read the client discovers only after hydration costs a full round-trip the payload could have carried. This is the largest lever in the app.
|
|
12
|
+
|
|
13
|
+
## 2. Client Data Fetching (query)
|
|
14
|
+
|
|
15
|
+
**Impact:** CRITICAL
|
|
16
|
+
**Description:** `useQuery` defaults (suspense on, keepPreviousData on, 5s staleTime, focus revalidation off) are opinionated. Fighting them — or not knowing them — produces blank flashes, dead pagination, and duplicate requests.
|
|
17
|
+
|
|
18
|
+
## 3. Data Access — ORM (orm)
|
|
19
|
+
|
|
20
|
+
**Impact:** HIGH
|
|
21
|
+
**Description:** The ORM is Prisma-typed but gemi-executed, with its own relation-loading strategies, transaction semantics, connection selection, and row-provenance model. Its constraints are not Prisma's.
|
|
22
|
+
|
|
23
|
+
## 4. Routing & Middleware (routing)
|
|
24
|
+
|
|
25
|
+
**Impact:** HIGH
|
|
26
|
+
**Description:** Routing is class-based: routers declare a routes object, and middleware attaches as a string DSL. This is where a URL, its cache policy, and its auth boundary are all defined.
|
|
27
|
+
|
|
28
|
+
## 5. Controllers & Request Handling (controller)
|
|
29
|
+
|
|
30
|
+
**Impact:** HIGH
|
|
31
|
+
**Description:** Controllers hold server logic and own the request boundary — validation, authorization, error shape. The client's error handling is a contract with what a controller throws.
|
|
32
|
+
|
|
33
|
+
## 6. Services, Jobs & Commands (service)
|
|
34
|
+
|
|
35
|
+
**Impact:** HIGH
|
|
36
|
+
**Description:** Long-lived singletons, background work, and one-off ops. Most failures here are invisible in development and appear only in the production build or on a restart.
|
|
37
|
+
|
|
38
|
+
## 7. Client Components & Navigation (client)
|
|
39
|
+
|
|
40
|
+
**Impact:** MEDIUM
|
|
41
|
+
**Description:** React 19 SSR with hydration. Typed navigation, forms, suspense boundaries declared by route module exports, and data flow that does not go through effects.
|
|
42
|
+
|
|
43
|
+
## 8. Bundle Size (bundle)
|
|
44
|
+
|
|
45
|
+
**Impact:** MEDIUM
|
|
46
|
+
**Description:** Vite code-splits per route chunk. Client cost is determined by which module graph a route pulls in and where a heavy subtree is mounted.
|
|
47
|
+
|
|
48
|
+
## 9. Internationalization (i18n)
|
|
49
|
+
|
|
50
|
+
**Impact:** MEDIUM
|
|
51
|
+
**Description:** Two dictionary systems coexist. The newer one is rewritten at build time by a Vite plugin, which constrains how it may be written.
|
|
52
|
+
|
|
53
|
+
## 10. Testing (testing)
|
|
54
|
+
|
|
55
|
+
**Impact:** MEDIUM
|
|
56
|
+
**Description:** Views mount for real with seeded framework inputs, through `<Page>` from `gemi/testing`. Match whichever runner and naming convention the directory already uses — a test the runner never selects is a test that does not exist.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Rule Title Here
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Optional description of impact (e.g., "20-50% improvement")
|
|
5
|
+
tags: tag1, tag2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Rule Title Here
|
|
9
|
+
|
|
10
|
+
**Impact: MEDIUM (optional impact description)**
|
|
11
|
+
|
|
12
|
+
Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications.
|
|
13
|
+
|
|
14
|
+
**Incorrect (description of what's wrong):**
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
// Bad code example here
|
|
18
|
+
const bad = example()
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Correct (description of what's right):**
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// Good code example here
|
|
25
|
+
const good = example()
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Reference: [Link to documentation or resource](https://example.com)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Import UI Primitives By Deep Path
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: keeps a component library's dependencies out of routes that do not use it
|
|
5
|
+
tags: bundle, imports, barrel, ui
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Import UI Primitives By Deep Path
|
|
9
|
+
|
|
10
|
+
A component library where every primitive is its own module — the shadcn/ui shape,
|
|
11
|
+
and the shape most in-repo `packages/ui` workspaces take — should be imported by deep
|
|
12
|
+
path. A route then pulls in exactly the primitives it uses.
|
|
13
|
+
|
|
14
|
+
A barrel import, or adding a barrel that re-exports everything, pulls the whole
|
|
15
|
+
library and its transitive dependencies (for a Radix-based kit, ~25 packages) into
|
|
16
|
+
every route that touches one button. Tree-shaking does not reliably save you here:
|
|
17
|
+
the library is often source-only with no build step, and a single side-effecting
|
|
18
|
+
module in the barrel's graph defeats it.
|
|
19
|
+
|
|
20
|
+
**Incorrect (drags the library in):**
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Button, Input, Badge } from "@acme/ui";
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Correct (one module each):**
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { Button } from "@acme/ui/components/ui/button";
|
|
30
|
+
import { Input } from "@acme/ui/components/ui/input";
|
|
31
|
+
import { Badge } from "@acme/ui/components/ui/badge";
|
|
32
|
+
import { cn } from "@acme/ui/lib/utils";
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Do not add a barrel** to make the imports shorter. The deep paths are the
|
|
36
|
+
mechanism, not a style preference.
|
|
37
|
+
|
|
38
|
+
Two related notes:
|
|
39
|
+
|
|
40
|
+
- **Check whether your repo has a legacy copy.** A local `app/views/components/ui`
|
|
41
|
+
alongside a shared `packages/ui` means two divergent sets of the same primitives;
|
|
42
|
+
pick the shared one and say so in `CLAUDE.md`.
|
|
43
|
+
- **Keep import paths statically analyzable.** A computed specifier defeats
|
|
44
|
+
tree-shaking and widens what the build has to trace.
|
|
45
|
+
|
|
46
|
+
This matters more under gemi than under a bundler that ships one chunk: each view is
|
|
47
|
+
its own build entry (`preserveEntrySignatures: "strict"`), so a barrel import in one
|
|
48
|
+
view inflates that view's chunk and every chunk that shares its graph.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Put Heavy Subtrees Inside the Thing That Unmounts Them
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: ~47 kB and two queries off the initial load, measured
|
|
5
|
+
tags: bundle, mounting, radix, queries
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Put Heavy Subtrees Inside the Thing That Unmounts Them
|
|
9
|
+
|
|
10
|
+
The cheapest work is work that never mounts. **Let an unmounted subtree be the
|
|
11
|
+
gate**: Radix `PopoverContent`, `DialogContent`,
|
|
12
|
+
`DropdownMenuContent` and the tab primitives do not render their children while
|
|
13
|
+
closed. A component placed inside them costs nothing until the user opens the thing.
|
|
14
|
+
|
|
15
|
+
Hoisting a query or a heavy component *above* that boundary "to keep the parent
|
|
16
|
+
tidy" is how it starts running on every page load for a panel nobody opened. In this
|
|
17
|
+
app that cost ~47 kB resolved across two reads, on every load of the customer app.
|
|
18
|
+
|
|
19
|
+
**Incorrect (reads run on every page load to fill a closed popover):**
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
function ProductPicker() {
|
|
23
|
+
const { data: products } = useQuery("/app/:orgId/products/search", { params });
|
|
24
|
+
const { data: lists } = useQuery("/app/:orgId/lists", { params });
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Popover>
|
|
28
|
+
<PopoverTrigger>Add product</PopoverTrigger>
|
|
29
|
+
<PopoverContent>
|
|
30
|
+
<CatalogSearchPanel products={products} lists={lists} />
|
|
31
|
+
</PopoverContent>
|
|
32
|
+
</Popover>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Correct (the reads move down, inside the content Radix unmounts):**
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
function ProductPicker() {
|
|
41
|
+
return (
|
|
42
|
+
<Popover>
|
|
43
|
+
<PopoverTrigger>Add product</PopoverTrigger>
|
|
44
|
+
<PopoverContent>
|
|
45
|
+
<CatalogSearchPanel orgId={orgId} />
|
|
46
|
+
</PopoverContent>
|
|
47
|
+
</Popover>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function CatalogSearchPanel({ orgId }: { orgId: string }) {
|
|
52
|
+
const { data: products = [] } = useQuery(/* … */, { suspense: false });
|
|
53
|
+
const { data: lists = [] } = useQuery(/* … */, { suspense: false });
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Why mounting rather than `{ lazy: true }`:** a lazy query does not refetch when
|
|
58
|
+
its variant changes, so a searchable or paginated read breaks under it — see
|
|
59
|
+
`query-lazy-vs-mount-gate`.
|
|
60
|
+
|
|
61
|
+
**For a genuinely heavy module** (a chart library, an editor, a PDF renderer) plain
|
|
62
|
+
`React.lazy` + `Suspense` is the right tool; gemi ships no wrapper of its own for
|
|
63
|
+
it, and none is needed.
|