From 9af0f9bc5d17432a1576884d3ea10a9a60892208 Mon Sep 17 00:00:00 2001 From: Marco Falkenberg Date: Mon, 28 Sep 2026 13:15:21 +0200 Subject: [PATCH] feat: add React-free ./model entry point Framework-agnostic model code only needs the GhostMakerModel decorator and the model name/id accessors, but the main entry loads react, react/jsx-runtime and @tanstack/react-query. The new @mittwald/react-ghostmaker/model entry exposes GhostMakerModel, ghostMakerModel, getModelName, getModelId and the GhostMakerModelMeta and DynamicModel types without importing React, not even in its type declarations. getModelName and getModelId move from queries.ts to metaData.ts, because queries.ts depends on the React-bound types.ts. The main entry keeps exporting them unchanged and now also exports the two types. react and @tanstack/react-query become optional peer dependencies, as only the main entry needs them. A test walks the import graph of the ./model and ./polytype entries (including type-only imports) and fails as soon as either reaches react, react-dom or @tanstack/react-query. Co-Authored-By: Claude Opus 5.5 --- README.md | 24 ++++++++ packages/react-ghostmaker/package.json | 10 ++++ packages/react-ghostmaker/src/index.ts | 10 +++- packages/react-ghostmaker/src/metaData.ts | 17 +++++- packages/react-ghostmaker/src/model.ts | 8 +++ packages/react-ghostmaker/src/queries.ts | 15 +---- .../src/reactFreeEntries.test.ts | 55 +++++++++++++++++++ 7 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 packages/react-ghostmaker/src/model.ts create mode 100644 packages/react-ghostmaker/src/reactFreeEntries.test.ts diff --git a/README.md b/README.md index 19a7380..6a785d9 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,27 @@ class Blog { - Name and ID can be set explicitly (for readable query keys) - Works for multiple models and inheritance +#### Framework-agnostic model code + +If your models live in code that must not depend on React (a shared model +package, server-side or CLI code), import the decorator from the +`@mittwald/react-ghostmaker/model` entry point instead of the main entry: + +```ts +import { GhostMakerModel } from "@mittwald/react-ghostmaker/model"; +``` + +This entry point only contains what is needed to describe models — +`GhostMakerModel`, `ghostMakerModel`, `getModelName`, `getModelId` and the +`GhostMakerModelMeta` and `DynamicModel` types — and imports neither React nor +TanStack Query, not even for its type declarations. `react` and +`@tanstack/react-query` are therefore optional peer dependencies: you only need +them where you import from the main entry (`@mittwald/react-ghostmaker`), i.e. +where you actually create and use ghosts. + +Both entry points share the same model registry, so models decorated via +`/model` are recognized by ghosts created via the main entry. + #### Alternative: `registerModelIdentifier` (deprecated) The previous `registerModelIdentifier` function is still available but marked as deprecated. It can be used to centrally register IDs for models: @@ -861,6 +882,9 @@ Creates a ghost proxy from any object or class instance. - TanStack Query ^5 - TypeScript (recommended) +React and TanStack Query are only required for the main entry point, not for +[`@mittwald/react-ghostmaker/model`](#framework-agnostic-model-code). + ## 📄 License MIT © [Mittwald CM Service GmbH & Co. KG](https://github.com/mittwald) diff --git a/packages/react-ghostmaker/package.json b/packages/react-ghostmaker/package.json index 9b8725b..1faca35 100644 --- a/packages/react-ghostmaker/package.json +++ b/packages/react-ghostmaker/package.json @@ -27,6 +27,10 @@ "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.js" }, + "./model": { + "types": "./dist/types/model.d.ts", + "import": "./dist/esm/model.js" + }, "./polytype": { "types": "./dist/types/polytype.d.ts", "import": "./dist/esm/polytype.js" @@ -90,8 +94,14 @@ "react": ">=19.2" }, "peerDependenciesMeta": { + "@tanstack/react-query": { + "optional": true + }, "polytype": { "optional": true + }, + "react": { + "optional": true } } } diff --git a/packages/react-ghostmaker/src/index.ts b/packages/react-ghostmaker/src/index.ts index 468de3d..a2c7933 100644 --- a/packages/react-ghostmaker/src/index.ts +++ b/packages/react-ghostmaker/src/index.ts @@ -4,5 +4,11 @@ export { type ReactGhost, UseGhostReturn } from "./types.ts"; export { getQueryContext, forwardQueryContext } from "./context.ts"; export { invalidateGhosts } from "./invalidate.ts"; export * from "./maybeGhost/index.ts"; -export { GhostMakerModel, ghostMakerModel } from "./metaData.ts"; -export { getModelId, getModelName } from "./queries.ts"; +export { + GhostMakerModel, + ghostMakerModel, + getModelId, + getModelName, + type DynamicModel, + type GhostMakerModelMeta, +} from "./metaData.ts"; diff --git a/packages/react-ghostmaker/src/metaData.ts b/packages/react-ghostmaker/src/metaData.ts index b6939e1..5b7f617 100644 --- a/packages/react-ghostmaker/src/metaData.ts +++ b/packages/react-ghostmaker/src/metaData.ts @@ -3,7 +3,7 @@ import type { AbstractClass, Class } from "type-fest"; type DecoratorTarget = Class | AbstractClass; -interface GhostMakerModelMeta { +export interface GhostMakerModelMeta { getId?: (instance: InstanceType) => string; name?: string; } @@ -36,7 +36,7 @@ export function GhostMakerModel( }; } -type DynamicModel = ( +export type DynamicModel = ( something: unknown, ) => GhostMakerModelMeta | void | undefined; @@ -66,6 +66,19 @@ export const getMetaData = ( ); }; +export const getModelName = (something: unknown) => { + return getMetaData(something)?.name; +}; + +export const getModelId = (something: unknown): string | undefined => { + if (is.object(something)) { + const getId = getMetaData(something)?.getId; + if (getId) { + return getId(something); + } + } +}; + const getMetaDataRecursive = ( something: unknown, collectedMeta?: Partial>, diff --git a/packages/react-ghostmaker/src/model.ts b/packages/react-ghostmaker/src/model.ts new file mode 100644 index 0000000..42af102 --- /dev/null +++ b/packages/react-ghostmaker/src/model.ts @@ -0,0 +1,8 @@ +export { + GhostMakerModel, + ghostMakerModel, + getModelId, + getModelName, + type DynamicModel, + type GhostMakerModelMeta, +} from "./metaData.ts"; diff --git a/packages/react-ghostmaker/src/queries.ts b/packages/react-ghostmaker/src/queries.ts index e4e06a5..07b977c 100644 --- a/packages/react-ghostmaker/src/queries.ts +++ b/packages/react-ghostmaker/src/queries.ts @@ -1,22 +1,9 @@ import is from "@sindresorhus/is"; import type { GhostChain, GhostChainItem, QueryKey } from "./types.ts"; import { hashObject } from "./hash.ts"; -import { getMetaData } from "./metaData.ts"; +import { getModelId, getModelName } from "./metaData.ts"; import { modelIdentifiers } from "./modelIdentifier.ts"; -export const getModelName = (something: unknown) => { - return getMetaData(something)?.name; -}; - -export const getModelId = (something: unknown): string | undefined => { - if (is.object(something)) { - const getId = getMetaData(something)?.getId; - if (getId) { - return getId(something); - } - } -}; - const getObjectName = (something: unknown) => { const modelName = getModelName(something); if (modelName) { diff --git a/packages/react-ghostmaker/src/reactFreeEntries.test.ts b/packages/react-ghostmaker/src/reactFreeEntries.test.ts new file mode 100644 index 0000000..70c6b8d --- /dev/null +++ b/packages/react-ghostmaker/src/reactFreeEntries.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import ts from "typescript"; +import { describe, expect, test } from "vitest"; + +const srcDir = __dirname; + +const isReactPackage = (specifier: string) => + /^(react|react-dom|@tanstack\/react-query)(\/|$)/.test(specifier); + +const collectPackageImports = (entry: string): Map => { + const packageImports = new Map(); + const visited = new Set(); + const queue = [resolve(srcDir, entry)]; + + while (queue.length > 0) { + const file = queue.pop()!; + if (visited.has(file)) { + continue; + } + visited.add(file); + + const { importedFiles } = ts.preProcessFile( + readFileSync(file, "utf8"), + true, + true, + ); + + for (const { fileName: specifier } of importedFiles) { + if (specifier.startsWith(".")) { + queue.push(resolve(dirname(file), specifier)); + } else { + const importers = packageImports.get(specifier) ?? []; + importers.push(file.slice(srcDir.length + 1)); + packageImports.set(specifier, importers); + } + } + } + + return packageImports; +}; + +describe.each(["model.ts", "polytype.ts"])("entry point %s", (entry) => { + test("does not import React or TanStack Query, not even for types", () => { + const reactImports = Array.from(collectPackageImports(entry)).filter( + ([specifier]) => isReactPackage(specifier), + ); + + expect(Object.fromEntries(reactImports)).toEqual({}); + }); +}); + +test("the import walker detects React imports", () => { + expect(collectPackageImports("index.ts").has("react")).toBe(true); +});