diff --git a/examples/with-hooks-store/ice.config.mts b/examples/with-hooks-store/ice.config.mts
new file mode 100644
index 000000000..9b4b21c02
--- /dev/null
+++ b/examples/with-hooks-store/ice.config.mts
@@ -0,0 +1,8 @@
+import { defineConfig } from '@ice/app';
+import store from '@ice/plugin-hooks-store';
+
+export default defineConfig({
+ plugins: [
+ store(),
+ ],
+});
diff --git a/examples/with-hooks-store/package.json b/examples/with-hooks-store/package.json
new file mode 100644
index 000000000..3b02b53cc
--- /dev/null
+++ b/examples/with-hooks-store/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "with-hook-store",
+ "private": true,
+ "version": "1.0.0",
+ "scripts": {
+ "start": "ice start",
+ "build": "ice build"
+ },
+ "license": "MIT",
+ "dependencies": {
+ "@ice/app": "workspace:*",
+ "@ice/runtime": "workspace:*"
+ },
+ "devDependencies": {
+ "@ice/plugin-hooks-store": "workspace:*",
+ "@types/react": "^18.0.0",
+ "@types/react-dom": "^18.0.0",
+ "browserslist": "^4.19.3",
+ "regenerator-runtime": "^0.13.9"
+ }
+}
\ No newline at end of file
diff --git a/examples/with-hooks-store/public/favicon.ico b/examples/with-hooks-store/public/favicon.ico
new file mode 100644
index 000000000..a2605c57e
Binary files /dev/null and b/examples/with-hooks-store/public/favicon.ico differ
diff --git a/examples/with-hooks-store/src/app.tsx b/examples/with-hooks-store/src/app.tsx
new file mode 100644
index 000000000..c1664902e
--- /dev/null
+++ b/examples/with-hooks-store/src/app.tsx
@@ -0,0 +1,3 @@
+import { defineAppConfig } from 'ice';
+
+export default defineAppConfig({});
diff --git a/examples/with-hooks-store/src/document.tsx b/examples/with-hooks-store/src/document.tsx
new file mode 100644
index 000000000..a61df501e
--- /dev/null
+++ b/examples/with-hooks-store/src/document.tsx
@@ -0,0 +1,23 @@
+import { Meta, Title, Links, Main, Scripts } from 'ice';
+
+function Document() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default Document;
diff --git a/examples/with-hooks-store/src/models/useTodo.ts b/examples/with-hooks-store/src/models/useTodo.ts
new file mode 100644
index 000000000..ac76c0d86
--- /dev/null
+++ b/examples/with-hooks-store/src/models/useTodo.ts
@@ -0,0 +1,12 @@
+import { useState } from 'react';
+
+function useTodo() {
+ const [todos, setTodos] = useState([{ name: 'ICE', id: 'ICE' }]);
+
+ return {
+ todos,
+ setTodos,
+ };
+}
+
+export default useTodo;
diff --git a/examples/with-hooks-store/src/pages/about/index.tsx b/examples/with-hooks-store/src/pages/about/index.tsx
new file mode 100644
index 000000000..0ad5226d8
--- /dev/null
+++ b/examples/with-hooks-store/src/pages/about/index.tsx
@@ -0,0 +1,16 @@
+import { Link } from 'ice';
+import store from './store';
+
+function About() {
+ const { name, age } = store.useHook('useUser');
+ return (
+ <>
+ name: {name}
+ age: {age}
+
+ Home
+ >
+ );
+}
+
+export default About;
diff --git a/examples/with-hooks-store/src/pages/about/layout.tsx b/examples/with-hooks-store/src/pages/about/layout.tsx
new file mode 100644
index 000000000..b76c2b5f2
--- /dev/null
+++ b/examples/with-hooks-store/src/pages/about/layout.tsx
@@ -0,0 +1,9 @@
+import { Outlet } from 'ice';
+
+export default function layout() {
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/examples/with-hooks-store/src/pages/about/models/useUser.ts b/examples/with-hooks-store/src/pages/about/models/useUser.ts
new file mode 100644
index 000000000..18c1cd2c2
--- /dev/null
+++ b/examples/with-hooks-store/src/pages/about/models/useUser.ts
@@ -0,0 +1,6 @@
+export default function useUser() {
+ return {
+ name: 'ICE 3',
+ age: 5,
+ };
+}
diff --git a/examples/with-hooks-store/src/pages/about/store.ts b/examples/with-hooks-store/src/pages/about/store.ts
new file mode 100644
index 000000000..8092fa82d
--- /dev/null
+++ b/examples/with-hooks-store/src/pages/about/store.ts
@@ -0,0 +1,4 @@
+import { createStore } from 'ice';
+import useUser from './models/useUser';
+
+export default createStore({ useUser });
diff --git a/examples/with-hooks-store/src/pages/index.tsx b/examples/with-hooks-store/src/pages/index.tsx
new file mode 100644
index 000000000..09f44ed53
--- /dev/null
+++ b/examples/with-hooks-store/src/pages/index.tsx
@@ -0,0 +1,28 @@
+import { Link } from 'ice';
+import pageStore from './store';
+import appStore from '@/store';
+
+function Home() {
+ const { todos } = appStore.useHook('useTodo');
+ const { count, increment, decrement } = pageStore.useHook('useCounter');
+
+ return (
+ <>
+
+ ToDo List
+
+ {todos.map(todo => (- {todo.name}
))}
+
+
+
+
+ {count}
+
+
+ About
+ >
+ );
+}
+
+
+export default Home;
diff --git a/examples/with-hooks-store/src/pages/models/useCounter.ts b/examples/with-hooks-store/src/pages/models/useCounter.ts
new file mode 100644
index 000000000..ab10d03a1
--- /dev/null
+++ b/examples/with-hooks-store/src/pages/models/useCounter.ts
@@ -0,0 +1,15 @@
+import { useState } from 'react';
+
+function useCounter() {
+ const [count, setCount] = useState(0);
+ const increment = () => setCount(count + 1);
+ const decrement = () => setCount(count - 1);
+
+ return {
+ count,
+ increment,
+ decrement,
+ };
+}
+
+export default useCounter;
diff --git a/examples/with-hooks-store/src/pages/store.ts b/examples/with-hooks-store/src/pages/store.ts
new file mode 100644
index 000000000..8ad7c8e41
--- /dev/null
+++ b/examples/with-hooks-store/src/pages/store.ts
@@ -0,0 +1,4 @@
+import { createStore } from 'ice';
+import useCounter from './models/useCounter';
+
+export default createStore({ useCounter });
diff --git a/examples/with-hooks-store/src/store.ts b/examples/with-hooks-store/src/store.ts
new file mode 100644
index 000000000..a7a7bc3f2
--- /dev/null
+++ b/examples/with-hooks-store/src/store.ts
@@ -0,0 +1,4 @@
+import { createStore } from 'ice';
+import useTodo from './models/useTodo';
+
+export default createStore({ useTodo });
diff --git a/examples/with-hooks-store/tsconfig.json b/examples/with-hooks-store/tsconfig.json
new file mode 100644
index 000000000..7f2f2ffce
--- /dev/null
+++ b/examples/with-hooks-store/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "compileOnSave": false,
+ "buildOnSave": false,
+ "compilerOptions": {
+ "baseUrl": ".",
+ "outDir": "build",
+ "module": "esnext",
+ "target": "es6",
+ "jsx": "react-jsx",
+ "moduleResolution": "node",
+ "allowSyntheticDefaultImports": true,
+ "lib": ["es6", "dom"],
+ "sourceMap": true,
+ "allowJs": true,
+ "rootDir": "./",
+ "forceConsistentCasingInFileNames": true,
+ "noImplicitReturns": true,
+ "noImplicitThis": true,
+ "noImplicitAny": false,
+ "importHelpers": true,
+ "strictNullChecks": true,
+ "suppressImplicitAnyIndexErrors": true,
+ "noUnusedLocals": true,
+ "skipLibCheck": true,
+ "paths": {
+ "@/*": ["./src/*"],
+ "ice": [".ice"]
+ }
+ },
+ "include": ["src", ".ice", "ice.config.*"],
+ "exclude": ["node_modules", "build", "public"]
+}
\ No newline at end of file
diff --git a/packages/plugin-hooks-store/CHANGELOG.md b/packages/plugin-hooks-store/CHANGELOG.md
new file mode 100644
index 000000000..eb29e3cbe
--- /dev/null
+++ b/packages/plugin-hooks-store/CHANGELOG.md
@@ -0,0 +1,5 @@
+# CHANGELOG
+
+## 1.0.0
+
+- feat: init plugin
diff --git a/packages/plugin-hooks-store/README.md b/packages/plugin-hooks-store/README.md
new file mode 100644
index 000000000..50c1be6f8
--- /dev/null
+++ b/packages/plugin-hooks-store/README.md
@@ -0,0 +1,16 @@
+# @ice/plugin-hooks-store
+
+A plugin of lightweight React Hooks state management used in framework `ICE`.
+
+## Usage
+
+```ts
+import { defineConfig } from '@ice/app';
+import store from '@ice/plugin-hooks-store';
+
+export default defineConfig({
+ plugins: [
+ store(),
+ ],
+});
+```
diff --git a/packages/plugin-hooks-store/package.json b/packages/plugin-hooks-store/package.json
new file mode 100644
index 000000000..56d538e42
--- /dev/null
+++ b/packages/plugin-hooks-store/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "@ice/plugin-hooks-store",
+ "version": "1.0.0",
+ "description": "A plugin of lightweight React Hooks state management based on @ice/hooks-store.",
+ "license": "MIT",
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./esm/index.d.ts",
+ "import": "./esm/index.js",
+ "default": "./esm/index.js"
+ },
+ "./runtime": {
+ "types": "./esm/runtime.d.ts",
+ "import": "./esm/runtime.js",
+ "default": "./esm/runtime.js"
+ },
+ "./api": {
+ "types": "./esm/api.d.ts",
+ "import": "./esm/api.js",
+ "default": "./esm/api.js"
+ }
+ },
+ "main": "./esm/index.js",
+ "types": "./esm/index.d.ts",
+ "files": [
+ "esm",
+ "!esm/**/*.map"
+ ],
+ "dependencies": {
+ "@ice/hooks-store": "^1.0.0-alpha.0",
+ "fast-glob": "^3.2.11",
+ "micromatch": "^4.0.5"
+ },
+ "devDependencies": {
+ "@ice/types": "^1.0.0",
+ "@types/react": "^18.0.0",
+ "@types/react-dom": "^18.0.0",
+ "@types/micromatch": "^4.0.2",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "regenerator-runtime": "^0.13.9"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ },
+ "repository": {
+ "type": "http",
+ "url": "https://github.com/ice-lab/ice-next/tree/master/packages/plugin-hook-store"
+ },
+ "scripts": {
+ "watch": "tsc -w",
+ "build": "tsc"
+ }
+}
\ No newline at end of file
diff --git a/packages/plugin-hooks-store/src/_store.ts b/packages/plugin-hooks-store/src/_store.ts
new file mode 100644
index 000000000..e9ffe6434
--- /dev/null
+++ b/packages/plugin-hooks-store/src/_store.ts
@@ -0,0 +1,10 @@
+/**
+ * This file which is imported by the runtime.tsx, is to avoid TS error.
+ */
+import { createStore } from '@ice/hooks-store';
+
+const models = {};
+
+const store: ReturnType = createStore(models);
+
+export default store;
diff --git a/packages/plugin-hooks-store/src/api.ts b/packages/plugin-hooks-store/src/api.ts
new file mode 100644
index 000000000..bda11584e
--- /dev/null
+++ b/packages/plugin-hooks-store/src/api.ts
@@ -0,0 +1 @@
+export { createStore } from '@ice/hooks-store';
diff --git a/packages/plugin-hooks-store/src/constants.ts b/packages/plugin-hooks-store/src/constants.ts
new file mode 100644
index 000000000..4c487edc6
--- /dev/null
+++ b/packages/plugin-hooks-store/src/constants.ts
@@ -0,0 +1,2 @@
+export const PAGE_STORE_MODULE = '__PAGE_STORE__';
+export const PAGE_STORE_PROVIDER = '__PAGE_STORE_PROVIDER__';
diff --git a/packages/plugin-hooks-store/src/index.ts b/packages/plugin-hooks-store/src/index.ts
new file mode 100644
index 000000000..af27b8a83
--- /dev/null
+++ b/packages/plugin-hooks-store/src/index.ts
@@ -0,0 +1,113 @@
+import * as path from 'path';
+import { fileURLToPath } from 'url';
+import fg from 'fast-glob';
+import type { Config, Plugin } from '@ice/types';
+import micromatch from 'micromatch';
+import { PAGE_STORE_MODULE, PAGE_STORE_PROVIDER } from './constants.js';
+
+const storeFilePattern = '**/store.{js,ts}';
+const ignoreStoreFilePatterns = ['**/models/**', storeFilePattern];
+
+const plugin: Plugin = () => ({
+ name: '@ice/plugin-hook-store',
+ setup: ({ onGetConfig, modifyUserConfig, generator, context: { rootDir, userConfig } }) => {
+ const srcDir = path.join(rootDir, 'src');
+ const pageDir = path.join(srcDir, 'pages');
+
+ modifyUserConfig('routes', {
+ ...(userConfig.routes || {}),
+ ignoreFiles: [...(userConfig?.routes?.ignoreFiles || []), ...ignoreStoreFilePatterns],
+ });
+
+ onGetConfig(config => {
+ // Add app store provider.
+ const appStorePath = getAppStorePath(srcDir);
+ if (appStorePath) {
+ config.alias = {
+ ...config.alias || {},
+ $store: appStorePath,
+ };
+ }
+ config.transformPlugins = [
+ ...(config.transformPlugins || []),
+ exportStoreProviderPlugin({ pageDir }),
+ ];
+ return config;
+ });
+
+ // Export store api: createStore, createModel from `.ice/index.ts`.
+ generator.addExport({
+ specifier: ['createStore'],
+ source: '@ice/plugin-hooks-store/api',
+ type: false,
+ });
+ },
+ runtime: path.join(path.dirname(fileURLToPath(import.meta.url)), 'runtime.js'),
+});
+
+
+function exportStoreProviderPlugin({ pageDir }: { pageDir: string }): Config['transformPlugins'][0] {
+ return {
+ name: 'export-store-provider',
+ enforce: 'post',
+ transformInclude: (id) => {
+ return id.startsWith(pageDir) && !micromatch.isMatch(id, ignoreStoreFilePatterns);
+ },
+ transform: async (source, id) => {
+ const pageStorePath = getPageStorePath(id);
+ if (pageStorePath) {
+ if (
+ isLayout(id) || // Current id is layout.
+ !isLayoutExisted(id) // If current id is route and there is no layout in the current dir.
+ ) {
+ return exportPageStore(source);
+ }
+ }
+ return source;
+ },
+ };
+}
+
+function exportPageStore(source: string) {
+ const importStoreStatement = `import ${PAGE_STORE_MODULE} from './store';\n`;
+ const exportStoreProviderStatement = `
+const { Provider: ${PAGE_STORE_PROVIDER} } = ${PAGE_STORE_MODULE};
+export { ${PAGE_STORE_PROVIDER} };`;
+
+ return importStoreStatement + source + exportStoreProviderStatement;
+}
+
+/**
+ * Get the page store path which is at the same directory level.
+ * @param {string} id Route absolute path.
+ * @returns {string|undefined}
+ */
+function getPageStorePath(id: string): string | undefined {
+ const dir = path.dirname(id);
+ const result = fg.sync(storeFilePattern, { cwd: dir, deep: 1 });
+ return result.length ? path.join(dir, result[0]) : undefined;
+}
+
+function isLayout(id: string): boolean {
+ const extname = path.extname(id);
+ const idWithoutExtname = id.substring(0, id.length - extname.length);
+ return idWithoutExtname.endsWith('layout');
+}
+
+/**
+ * Check the current route component if there is layout.tsx at the same directory level.
+ * @param {string} id Route absolute path.
+ * @returns {boolean}
+ */
+function isLayoutExisted(id: string): boolean {
+ const dir = path.dirname(id);
+ const result = fg.sync('layout.{js,jsx,tsx}', { cwd: dir, deep: 1 });
+ return !!result.length;
+}
+
+function getAppStorePath(srcPath: string) {
+ const result = fg.sync(storeFilePattern, { cwd: srcPath, deep: 1 });
+ return result.length ? path.join(srcPath, result[0]) : undefined;
+}
+
+export default plugin;
diff --git a/packages/plugin-hooks-store/src/runtime.tsx b/packages/plugin-hooks-store/src/runtime.tsx
new file mode 100644
index 000000000..be0817c13
--- /dev/null
+++ b/packages/plugin-hooks-store/src/runtime.tsx
@@ -0,0 +1,36 @@
+import * as React from 'react';
+import { createStore } from '@ice/hooks-store';
+import type { RuntimePlugin, AppProvider, RouteWrapper } from '@ice/types';
+import { PAGE_STORE_PROVIDER } from './constants.js';
+import appStore from '$store';
+
+const runtime: RuntimePlugin = async ({ addProvider, useAppContext, addWrapper }) => {
+ if (appStore && Object.prototype.hasOwnProperty.call(appStore, 'Provider')) {
+ // Add app store Provider
+ const StoreProvider: AppProvider = ({ children }) => {
+ return (
+ // TODO: support initialStates: https://github.com/ice-lab/ice-next/issues/395#issuecomment-1210552931
+
+ {children}
+
+ );
+ };
+ addProvider(StoreProvider);
+ }
+
+ // page store
+ const StoreProviderWrapper: RouteWrapper = ({ children, routeId }) => {
+ const { routeModules } = useAppContext();
+ const routeModule = routeModules[routeId];
+ if (routeModule[PAGE_STORE_PROVIDER]) {
+ const Provider = routeModule[PAGE_STORE_PROVIDER];
+ return {children};
+ }
+ return <>{children}>;
+ };
+
+ addWrapper(StoreProviderWrapper, true);
+};
+
+export { createStore };
+export default runtime;
diff --git a/packages/plugin-hooks-store/tsconfig.json b/packages/plugin-hooks-store/tsconfig.json
new file mode 100644
index 000000000..c98231291
--- /dev/null
+++ b/packages/plugin-hooks-store/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "baseUrl": "./",
+ "rootDir": "src",
+ "outDir": "esm",
+ "jsx": "react",
+ "paths": {
+ "$store": ["./src/_store"]
+ }
+ },
+ "include": ["src"]
+}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 479b517dd..9e851ec1b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -279,6 +279,25 @@ importers:
'@types/react': 18.0.8
'@types/react-dom': 18.0.3
+ examples/with-hooks-store:
+ specifiers:
+ '@ice/app': workspace:*
+ '@ice/plugin-hooks-store': workspace:*
+ '@ice/runtime': workspace:*
+ '@types/react': ^18.0.0
+ '@types/react-dom': ^18.0.0
+ browserslist: ^4.19.3
+ regenerator-runtime: ^0.13.9
+ dependencies:
+ '@ice/app': link:../../packages/ice
+ '@ice/runtime': link:../../packages/runtime
+ devDependencies:
+ '@ice/plugin-hooks-store': link:../../packages/plugin-hooks-store
+ '@types/react': 18.0.17
+ '@types/react-dom': 18.0.6
+ browserslist: 4.21.3
+ regenerator-runtime: 0.13.9
+
examples/with-pha:
specifiers:
'@ice/app': workspace:*
@@ -626,6 +645,31 @@ importers:
devDependencies:
'@ice/types': link:../types
+ packages/plugin-hooks-store:
+ specifiers:
+ '@ice/hooks-store': ^1.0.0-alpha.0
+ '@ice/types': ^1.0.0
+ '@types/micromatch': ^4.0.2
+ '@types/react': ^18.0.0
+ '@types/react-dom': ^18.0.0
+ fast-glob: ^3.2.11
+ micromatch: ^4.0.5
+ react: ^18.2.0
+ react-dom: ^18.2.0
+ regenerator-runtime: ^0.13.9
+ dependencies:
+ '@ice/hooks-store': 1.0.0-alpha.0_react@18.2.0
+ fast-glob: 3.2.11
+ micromatch: 4.0.5
+ devDependencies:
+ '@ice/types': link:../types
+ '@types/micromatch': 4.0.2
+ '@types/react': 18.0.17
+ '@types/react-dom': 18.0.6
+ react: 18.2.0
+ react-dom: 18.2.0_react@18.2.0
+ regenerator-runtime: 0.13.9
+
packages/plugin-moment-locales:
specifiers:
'@ice/types': ^1.0.0
@@ -5282,6 +5326,14 @@ packages:
resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==}
dev: true
+ /@ice/hooks-store/1.0.0-alpha.0_react@18.2.0:
+ resolution: {integrity: sha512-ElEkwhiyZBfb5TdbpmbknSoXWIK3365mmqMx+i4VU5MSXgb9ciAfcT2tRo8poni7TFHtqtZe/cyBQRTnx+IN3A==}
+ peerDependencies:
+ react: ^16.8 || ^17 || ^18
+ dependencies:
+ react: 18.2.0
+ dev: false
+
/@ice/pkg-plugin-component/1.0.0:
resolution: {integrity: sha512-Mff6Em1RwY2NOZgciQyy2NXL65u9ebHV2Jqb5746vUVEh0l7Xpokb+3djV1pKcgED4wU8pSuSQ6jj+hXd9Ht1Q==}
dependencies: