Skip to content

Commit

Permalink
Merge pull request #1 from recruit-tech/setup
Browse files Browse the repository at this point in the history
Migrate core implementation and testing
  • Loading branch information
AkifumiSato authored Aug 25, 2023
2 parents f9d9f98 + f126de8 commit 1a9e236
Show file tree
Hide file tree
Showing 35 changed files with 5,360 additions and 1 deletion.
29 changes: 29 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# dependencies
/**/node_modules
/.pnp
.pnp.js

# testing
/**/coverage

# distribution
/**/dist

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# local env files
.env*.local

# typescript
*.tsbuildinfo

# turbo
.turbo
20 changes: 19 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,24 @@
"keywords": [],
"license": "MIT",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"core": "pnpm -F \"location-state-core\"",
"next": "pnpm -F \"location-state-next\"",
"build": "turbo build",
"test": "turbo test",
"typecheck": "turbo typecheck",
"check": "turbo typecheck build test"
},
"devDependencies": {
"@types/jest": "29.5.3",
"@types/node": "20.4.5",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"jest": "29.6.1",
"navigation-api-types": "0.3.1",
"react": "18.2.0",
"ts-jest": "29.1.1",
"tsup": "7.2.0",
"turbo": "1.10.13",
"typescript": "5.1.6"
}
}
2 changes: 2 additions & 0 deletions packages/location-state-core/jest-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import "@testing-library/jest-dom";
import "@testing-library/jest-dom/extend-expect";
10 changes: 10 additions & 0 deletions packages/location-state-core/jest.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** @type {import('jest').Config} */
const config = {
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
}

export default config
24 changes: 24 additions & 0 deletions packages/location-state-core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@location-state/core",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "tsup src/index.ts",
"typecheck": "tsc --noEmit",
"test": "jest"
},
"devDependencies": {
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
"@types/testing-library__jest-dom": "5.14.9",
"@types/uuid": "9.0.2",
"client-only": "0.0.1",
"jest-environment-jsdom": "29.6.3",
"test-utils": "workspace:*",
"uuid": "9.0.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
}
6 changes: 6 additions & 0 deletions packages/location-state-core/src/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { Store } from "./stores/types";
import { createContext } from "react";

export const LocationStateContext = createContext<{
stores: Record<string, Store>;
}>({ stores: {} });
38 changes: 38 additions & 0 deletions packages/location-state-core/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { LocationStateContext } from "./context";
import { StoreName } from "./types";
import { useCallback, useContext, useSyncExternalStore } from "react";

export const useLocationState = <T>({
name,
defaultValue,
storeName,
}: {
name: string;
defaultValue: T;
storeName: StoreName | string;
}): [T, (value: T) => void] => {
const { stores } = useContext(LocationStateContext);
const store = stores[storeName];
if (!store) {
// todo: fix message
throw new Error("Provider is required");
}
const subscribe = useCallback(
(onStoreChange: () => void) => store.subscribe(name, onStoreChange),
[name, store],
);
// `defaultValue` is assumed to always be the same value (for Objects, it must be memoized).
const storeState = useSyncExternalStore(
subscribe,
() => (store.get(name) as T) ?? defaultValue,
() => defaultValue,
);
const setStoreState = useCallback(
// todo: accept functions like useState
(value: T) => {
store.set(name, value);
},
[name, store],
);
return [storeState, setStoreState];
};
6 changes: 6 additions & 0 deletions packages/location-state-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './hooks'
export * from './provider'
export * from './types'
export * from './syncers'
export * from './unsafe-navigation'
export * from './stores'
59 changes: 59 additions & 0 deletions packages/location-state-core/src/location-sync.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useLocationState } from "./hooks";
import { LocationStateProvider } from "./provider";
import { createNavigationMock } from "test-utils";
import { renderWithUser } from "test-utils";
import { screen, waitFor } from "@testing-library/react";

function LocationSyncCounter() {
const [counter, setCounter] = useLocationState({
name: "counter",
defaultValue: 0,
storeName: "session",
});
return (
<div>
<h1>counter: {counter}</h1>
<button onClick={() => setCounter(counter + 1)}>increment</button>
</div>
);
}

function LocationSyncCounterPage() {
return (
<LocationStateProvider>
<LocationSyncCounter />
</LocationStateProvider>
);
}

const mockNavigation = createNavigationMock("/");
// @ts-ignore
globalThis.navigation = mockNavigation;

beforeEach(() => {
mockNavigation.navigate("/");
sessionStorage.clear();
});

test("`counter` can be updated.", async () => {
// Arrange
mockNavigation.navigate("/counter-update");
const { user } = renderWithUser(<LocationSyncCounterPage />);
// Act
await user.click(await screen.findByRole("button", { name: "increment" }));
// Assert
expect(screen.getByRole("heading")).toHaveTextContent("counter: 1");
});

test("`counter` is reset at navigation.", async () => {
// Arrange
mockNavigation.navigate("/counter-reset");
const { user } = renderWithUser(<LocationSyncCounterPage />);
await user.click(await screen.findByRole("button", { name: "increment" }));
// Act
mockNavigation.navigate("/anywhere");
// Assert
await waitFor(() =>
expect(screen.getByRole("heading")).toHaveTextContent("counter: 0"),
);
});
63 changes: 63 additions & 0 deletions packages/location-state-core/src/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { LocationStateContext } from "./context";
import { StorageStore } from "./stores/storage-store";
import { Store } from "./stores/types";
import { URLStore } from "./stores/url-store";
import { NavigationSyncer } from "./syncers/navigation-syncer";

import { Syncer } from "./types"
import { ReactNode, useEffect, useState } from "react";

export function LocationStateProvider({
children,
...props
}: {
syncer?: Syncer;
children: ReactNode;
}) {
const [syncer] = useState(
() => props.syncer ?? new NavigationSyncer(window.navigation),
);
const [contextValue] = useState(() => ({
stores: {
session: new StorageStore(globalThis.sessionStorage),
url: new URLStore("location-state", syncer),
},
}));

useEffect(() => {
const stores = contextValue.stores;
const abortController = new AbortController();
const { signal } = abortController;
const applyAllStore = (callback: (store: Store) => void) => {
Object.values(stores).forEach(callback);
};

const key = syncer.key()!;
applyAllStore((store) => store.load(key));

syncer.sync({
listener: (key) => {
applyAllStore((store) => {
store.save();
store.load(key);
});
},
signal,
});
window?.addEventListener(
"beforeunload",
() => {
applyAllStore((store) => store.save());
},
{ signal },
);

return () => abortController.abort();
}, [syncer, contextValue.stores]);

return (
<LocationStateContext.Provider value={contextValue}>
{children}
</LocationStateContext.Provider>
);
}
3 changes: 3 additions & 0 deletions packages/location-state-core/src/stores/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './storage-store'
export * from './url-store'
export * from './types'
Loading

0 comments on commit 1a9e236

Please sign in to comment.