Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Model data invalidation #178

Merged
merged 2 commits into from
Sep 23, 2024
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
2 changes: 1 addition & 1 deletion .idea/.name

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

mfal marked this conversation as resolved.
Show resolved Hide resolved
File renamed without changes.
2 changes: 1 addition & 1 deletion .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions .idea/nx-angular-config.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

777 changes: 756 additions & 21 deletions .pnp.cjs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/commons/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@
},
"dependencies": {
"@types/parse-path": "^7.0.3",
"axios": "^1.7.4",
"axios": "^1.7.7",
"parse-path": "^7.0.0",
"path-to-regexp": "^8.1.0",
"type-fest": "^4.23.0"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@mittwald/react-use-promise": "^2.3.13",
"@mittwald/react-use-promise": "^2.5.0",
"@types/jest": "^29.5.12",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/generator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"@types/prettier": "^3.0.0",
"@types/verror": "^1.10.10",
"@types/yieldable-json": "^2.0.2",
"axios": "^1.7.4",
"axios": "^1.7.7",
"camelcase": "^8.0.0",
"clone-deep": "^4.0.1",
"dot-prop": "^8.0.2",
Expand Down
2 changes: 1 addition & 1 deletion packages/mittwald/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
},
"devDependencies": {
"@mittwald/api-code-generator": "workspace:^",
"@mittwald/react-use-promise": "^2.3.13",
"@mittwald/react-use-promise": "^2.5.0",
"@types/node": "^20.14.14",
"@types/react": "^18.3.3",
"@typescript-eslint/eslint-plugin": "^7.18.0",
Expand Down
9 changes: 8 additions & 1 deletion packages/models/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,31 @@
"dependencies": {
"@mittwald/api-client": "workspace:^",
"another-deep-freeze": "^1.0.0",
"context": "^3.0.31",
"object-code": "^1.3.3",
"polytype": "^0.17.0",
"type-fest": "^4.23.0"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@mittwald/react-use-promise": "^2.3.13",
"@mittwald/react-use-promise": "^2.5.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@types/jest": "^29.5.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-json": "^3.1.0",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "^3.3.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"rimraf": "^5.0.10",
"ts-jest": "^29.2.4",
"typescript": "^5.5.4"
Expand Down
3 changes: 3 additions & 0 deletions packages/models/src/config/behaviors/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { apiServerBehaviors } from "../../server/Server/behaviors/index.js";
import { apiCustomerBehaviors } from "../../customer/Customer/behaviors/index.js";
import { apiIngressBehaviors } from "../../domain/Ingress/behaviors/index.js";
import { apiAppInstallationBehaviors } from "../../app/AppInstallation/behaviors/index.js";
import { updateCacheTagsBeforeRequest } from "../../react/asyncResourceInvalidation.js";

class ApiSetupState {
private _client: MittwaldAPIV2Client | undefined;
Expand All @@ -16,6 +17,8 @@ class ApiSetupState {
);
}
this._client = client;
this._client.defaultRequestOptions.onBeforeRequest =
updateCacheTagsBeforeRequest;

config.behaviors.project = apiProjectBehaviors(client);
config.behaviors.server = apiServerBehaviors(client);
Expand Down
2 changes: 1 addition & 1 deletion packages/models/src/project/Project/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export class Project extends ReferenceModel {
},
);

public query(query: ProjectListQueryModelData = {}) {
public static query(query: ProjectListQueryModelData = {}) {
return new ProjectListQuery(query);
}

Expand Down
16 changes: 0 additions & 16 deletions packages/models/src/react/MittwaldApiModelProvider.ts

This file was deleted.

25 changes: 25 additions & 0 deletions packages/models/src/react/MittwaldApiModelProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { FC, PropsWithChildren, ReactNode } from "react";
import { usePromise } from "@mittwald/react-use-promise";
import { setModule } from "./reactUsePromise.js";

interface Props extends PropsWithChildren {
fallback?: ReactNode;
}

export const MittwaldApiModelProvider: FC<Props> = (props) => {
const { fallback, children } = props;

const module = usePromise(
() => import("@mittwald/react-use-promise").then(setModule),
[],
{
useSuspense: false,
},
);

if (!module.hasValue) {
return fallback;
}

return children;
};
38 changes: 38 additions & 0 deletions packages/models/src/react/asyncResourceInvalidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Commons } from "@mittwald/api-client";
import { reactProvisionContext } from "./reactProvisionContext.js";
import { refresh } from "@mittwald/react-use-promise";
import { Store } from "@mittwald/react-use-promise/store";

const cacheTagStore = new Store<Set<string>>();

export const refreshModels = (tag: string) => {
cacheTagStore.getAll(tag).forEach((ids) => {
ids.forEach((id) => {
refresh({
tag: String(id),
});
});
});
};

export const addCacheTag = (tag: string) => {
const context = reactProvisionContext.use();

if (context) {
const ids = cacheTagStore.get(tag) ?? new Set<string>();
ids.add(context.id);

cacheTagStore.set(tag, () => ids, {
tags: [tag],
});
}
};

export const updateCacheTagsBeforeRequest: Commons.RequestOptions["onBeforeRequest"] =
(request) => {
const url = request.requestConfig.url;

if (request.requestConfig.method === "GET" && url) {
addCacheTag(url);
}
};
1 change: 1 addition & 0 deletions packages/models/src/react/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { refreshModels, addCacheTag } from "./asyncResourceInvalidation.js";
export * from "./MittwaldApiModelProvider.js";
export * from "./reactUsePromise.js";
export { type AsyncResourceVariant } from "./provideReact.js";
Expand Down
90 changes: 90 additions & 0 deletions packages/models/src/react/provideReact.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/** @jest-environment jsdom */

import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { ReferenceModel } from "../base/index.js";
import { provideReact } from "./provideReact.js";
import React, { act, FC, PropsWithChildren, Suspense } from "react";
import { beforeEach, jest } from "@jest/globals";
import { MittwaldApiModelProvider } from "./MittwaldApiModelProvider.js";
import { addCacheTag, refreshModels } from "./asyncResourceInvalidation.js";
import { asyncResourceStore } from "@mittwald/react-use-promise";

const simulatedDataLoad = jest.fn();
let rerender: ReturnType<typeof render>["rerender"] | undefined;

beforeEach(() => {
jest.resetAllMocks();
jest.useFakeTimers();
asyncResourceStore.clear();
rerender = undefined;

simulatedDataLoad.mockImplementation(() => {
return new Promise((res) => setTimeout(res, 100));
});
});

class TestModel extends ReferenceModel {
public static ofId(id: number) {
return new TestModel(String(id));
}

public getDetailed = provideReact(async () => {
addCacheTag(`test/get/${this.id}`);
await simulatedDataLoad();
return {
id: this.id,
foo: true,
};
}, [this.id]);
}

const TestComponent: FC<{ id: number }> = (props) => {
const model = TestModel.ofId(props.id).getDetailed.use();
return <span>{model.id}</span>;
};

const TestWrapper: FC<PropsWithChildren> = (props) => (
<MittwaldApiModelProvider>
<Suspense>{props.children}</Suspense>
</MittwaldApiModelProvider>
);

const runTest = async (id: number, expectedDataLoadingCount: number) => {
const ui = rerender
? rerender(<TestComponent id={id} />)
: render(<TestComponent id={id} />, {
wrapper: TestWrapper,
});

if (ui) {
rerender = ui.rerender;
}
expect(await screen.findByText(id)).toBeInTheDocument();
expect(simulatedDataLoad).toHaveBeenCalledTimes(expectedDataLoadingCount);
};

test("Model data can be used", async () => {
await runTest(42, 1);
await runTest(43, 2);
});

test("Model caches data", async () => {
await runTest(42, 1);
await runTest(43, 2);
await runTest(42, 2);
await runTest(43, 2);
});

test("Model cache can be refreshed", async () => {
await runTest(42, 1);
// Tag does not exist
act(() => refreshModels("foo"));
await runTest(42, 1);
// Tag exist
act(() => refreshModels("test/get/42"));
await runTest(42, 2);
// Tag exist
act(() => refreshModels("test/**/*"));
await runTest(42, 3);
});
28 changes: 23 additions & 5 deletions packages/models/src/react/provideReact.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { AsyncResource, reactUsePromise } from "./reactUsePromise.js";
import { AsyncReturnType } from "type-fest";
import { hash } from "object-code";
import { reactProvisionContext } from "./reactProvisionContext.js";

type AsyncFn = (...args: any[]) => Promise<unknown>;

Expand All @@ -8,13 +10,29 @@ export const provideReact = <T extends AsyncFn>(
dependencies?: string[],
) => {
type P = Parameters<T>;
const provisionId = String(
hash({
loader,
dependencies,
}),
);

const getAsyncResource = (params: P) =>
reactUsePromise.getAsyncResource(loader, params, {
// "stringify" dependencies to be used as loaderId
// see https://github.com/mittwald/react-use-promise?tab=readme-ov-file#loaderid
loaderId: dependencies ? dependencies.join("|") : undefined,
const getAsyncResource = (params: P) => {
const paramsHash = params && params.length > 0 ? String(hash(params)) : "";
const contextId = provisionId + paramsHash;

const loaderWithContext = reactProvisionContext.bind(
{
id: contextId,
},
loader,
);

return reactUsePromise.getAsyncResource(loaderWithContext, params, {
loaderId: provisionId,
tags: [contextId],
});
};

return Object.assign(loader, {
asResource: (...params: P) => getAsyncResource(params),
Expand Down
5 changes: 5 additions & 0 deletions packages/models/src/react/reactProvisionContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createCascade } from "context";

export const reactProvisionContext = createCascade<{
id: string;
}>();
3 changes: 2 additions & 1 deletion packages/models/src/react/reactUsePromise.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
type ReactUsePromiseModule = typeof import("@mittwald/react-use-promise");
export type ReactUsePromiseModule =
typeof import("@mittwald/react-use-promise");

export type {
AsyncResource,
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"skipLibCheck": true,
"strict": true,
"stripInternal": true,
"target": "ES2022"
"target": "ES2022",
"jsx": "react"
}
}
Loading