Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions packages/react-ghostmaker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -90,8 +94,14 @@
"react": ">=19.2"
},
"peerDependenciesMeta": {
"@tanstack/react-query": {
"optional": true
},
"polytype": {
"optional": true
},
"react": {
"optional": true
}
}
}
10 changes: 8 additions & 2 deletions packages/react-ghostmaker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
17 changes: 15 additions & 2 deletions packages/react-ghostmaker/src/metaData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { AbstractClass, Class } from "type-fest";

type DecoratorTarget = Class<unknown> | AbstractClass<unknown>;

interface GhostMakerModelMeta<T extends DecoratorTarget> {
export interface GhostMakerModelMeta<T extends DecoratorTarget> {
getId?: (instance: InstanceType<T>) => string;
name?: string;
}
Expand Down Expand Up @@ -36,7 +36,7 @@ export function GhostMakerModel<T extends DecoratorTarget>(
};
}

type DynamicModel = (
export type DynamicModel = (
something: unknown,
) => GhostMakerModelMeta<DecoratorTarget> | void | undefined;

Expand Down Expand Up @@ -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<GhostMakerModelMeta<DecoratorTarget>>,
Expand Down
8 changes: 8 additions & 0 deletions packages/react-ghostmaker/src/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
GhostMakerModel,
ghostMakerModel,
getModelId,
getModelName,
type DynamicModel,
type GhostMakerModelMeta,
} from "./metaData.ts";
15 changes: 1 addition & 14 deletions packages/react-ghostmaker/src/queries.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down
55 changes: 55 additions & 0 deletions packages/react-ghostmaker/src/reactFreeEntries.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> => {
const packageImports = new Map<string, string[]>();
const visited = new Set<string>();
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);
});
Loading