├── .changeset ├── config.json └── happy-baths-stop.md ├── .github └── workflows │ ├── check.yml │ ├── playwright.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── README.md ├── apps ├── example-next-basic │ ├── .gitignore │ ├── README.md │ ├── next.config.js │ ├── package.json │ ├── playwright.config.ts │ ├── playwright │ │ ├── save-on-use-effect.spec.ts │ │ ├── session-counter.spec.ts │ │ ├── session-list.spec.ts │ │ ├── url-counter.spec.ts │ │ └── url-list.spec.ts │ ├── src │ │ ├── app │ │ │ ├── _components │ │ │ │ └── Providers.tsx │ │ │ ├── dynamic │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── static │ │ │ │ └── page.tsx │ │ ├── components │ │ │ ├── Counter.tsx │ │ │ └── List.tsx │ │ └── pages │ │ │ ├── _app.tsx │ │ │ └── pages │ │ │ ├── index.tsx │ │ │ ├── other.tsx │ │ │ ├── save-on-use-effect │ │ │ └── index.tsx │ │ │ ├── ssg │ │ │ └── [id].tsx │ │ │ └── ssr │ │ │ └── [id].tsx │ └── tsconfig.json ├── example-next-conform │ ├── .gitignore │ ├── README.md │ ├── next.config.js │ ├── package.json │ ├── playwright.config.ts │ ├── playwright │ │ ├── session-form.spec.ts │ │ └── url-form.spec.ts │ ├── src │ │ └── app │ │ │ ├── forms │ │ │ └── [storeName] │ │ │ │ ├── dynamic-form │ │ │ │ ├── action.ts │ │ │ │ ├── form.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── schema.ts │ │ │ │ ├── simple-form │ │ │ │ ├── form.tsx │ │ │ │ └── page.tsx │ │ │ │ └── static-form │ │ │ │ ├── action.ts │ │ │ │ ├── form.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── schema.ts │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ ├── providers.tsx │ │ │ └── success │ │ │ └── page.tsx │ └── tsconfig.json ├── example-next-custom-store │ ├── .gitignore │ ├── README.md │ ├── next.config.js │ ├── package.json │ ├── src │ │ ├── app │ │ │ ├── _components │ │ │ │ └── Providers.tsx │ │ │ ├── dynamic │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── static │ │ │ │ └── page.tsx │ │ └── components │ │ │ ├── Counter.tsx │ │ │ └── List.tsx │ └── tsconfig.json └── example-next-unsafe-navigation │ ├── .gitignore │ ├── README.md │ ├── next.config.js │ ├── package.json │ ├── playwright.config.ts │ ├── playwright │ ├── session-counter.spec.ts │ ├── session-list.spec.ts │ ├── url-counter.spec.ts │ └── url-list.spec.ts │ ├── src │ ├── app │ │ ├── _components │ │ │ └── Providers.tsx │ │ ├── dynamic │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── static │ │ │ └── page.tsx │ └── components │ │ ├── Counter.tsx │ │ └── List.tsx │ └── tsconfig.json ├── biome.json ├── package.json ├── packages ├── configs │ ├── package.json │ ├── tsconfig.dts.json │ └── tsconfig.json ├── location-state-conform │ ├── CHANGELOG.md │ ├── README.md │ ├── docs │ │ └── API.md │ ├── package.json │ ├── src │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── schema.ts │ │ └── utils │ │ │ ├── updated-array.test.ts │ │ │ ├── updated-array.ts │ │ │ ├── updated-object.test.ts │ │ │ └── updated-object.ts │ ├── tsconfig.dts.json │ ├── tsconfig.json │ ├── tsup.config.ts │ └── vitest.config.mts ├── location-state-core │ ├── CHANGELOG.md │ ├── README.md │ ├── docs │ │ └── API.md │ ├── package.json │ ├── src │ │ ├── context.ts │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── location-sync.test.tsx │ │ ├── provider.tsx │ │ ├── stores │ │ │ ├── event-emitter.ts │ │ │ ├── in-memory-store.test.ts │ │ │ ├── in-memory-store.ts │ │ │ ├── index.ts │ │ │ ├── serializer.ts │ │ │ ├── storage-store.test.ts │ │ │ ├── storage-store.ts │ │ │ ├── types.ts │ │ │ ├── url-store.test.ts │ │ │ ├── url-store.ts │ │ │ └── utils │ │ │ │ ├── create-throttle.test.ts │ │ │ │ └── create-throttle.ts │ │ ├── syncers │ │ │ ├── index.ts │ │ │ ├── navigation-syncer.test.ts │ │ │ └── navigation-syncer.ts │ │ ├── types.ts │ │ └── unsafe-navigation.ts │ ├── tsconfig.dts.json │ ├── tsconfig.json │ ├── tsup.config.ts │ ├── vitest.config.mts │ └── vitest.setup.ts ├── location-state-next │ ├── CHANGELOG.md │ ├── README.md │ ├── docs │ │ └── API.md │ ├── package.json │ ├── src │ │ ├── hooks.ts │ │ ├── index.ts │ │ └── next-pages-syncer.ts │ ├── tsconfig.dts.json │ ├── tsconfig.json │ └── tsup.config.ts ├── test-utils │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── navigation.mock.ts │ │ └── render.tsx │ ├── tsconfig.json │ └── tsup.config.ts └── utils │ ├── package.json │ ├── src │ ├── asserts.ts │ └── type.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json └── turbo.json /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [ 6 | ["@location-state/core", "@location-state/next", "@location-state/conform"] 7 | ], 8 | "linked": [], 9 | "access": "public", 10 | "baseBranch": "main", 11 | "updateInternalDependencies": "patch", 12 | "ignore": ["example-*"], 13 | "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { 14 | "onlyUpdatePeerDependentsWhenOutOfRange": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.changeset/happy-baths-stop.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@location-state/core": minor 3 | --- 4 | 5 | Fixed an issue where state was being discarded even when the user attempted to save it using `useEffect` on initial render. 6 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | Test: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 30 11 | name: Run check project 12 | steps: 13 | - name: Checkout Repo 14 | uses: actions/checkout@v4 15 | with: 16 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 17 | fetch-depth: 0 18 | - name: Launch Turbo Remote Cache Server 19 | uses: dtinth/setup-github-actions-caching-for-turbo@v1.3.0 20 | - name: Install and cache nodejs 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: '22' 24 | - uses: pnpm/action-setup@v4 25 | name: Install pnpm 26 | with: 27 | version: 10.8.1 28 | run_install: false 29 | - name: Install packages 30 | run: pnpm i --ignore-scripts 31 | - name: Check project 32 | run: | 33 | pnpm run ci-check 34 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | Playwright: 9 | runs-on: ubuntu-latest 10 | timeout-minutes: 30 11 | name: Run playwright tests 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Launch Turbo Remote Cache Server 15 | uses: dtinth/setup-github-actions-caching-for-turbo@v1.3.0 16 | - name: Install and cache nodejs 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '22' 20 | - uses: pnpm/action-setup@v4 21 | name: Install pnpm 22 | with: 23 | version: 10.8.1 24 | run_install: false 25 | - name: Install packages 26 | run: pnpm i --ignore-scripts 27 | - name: Install playwright browsers 28 | run: | 29 | pnpm exec playwright install --with-deps chromium 30 | pnpm exec playwright install --with-deps webkit 31 | - name: Build packages 32 | run: pnpm build:packages 33 | - name: Run integration-test 34 | run: pnpm integration-test 35 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | release: 14 | name: Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Repo 18 | uses: actions/checkout@v4 19 | with: 20 | # This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits 21 | fetch-depth: 0 22 | - name: Install and cache nodejs 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: '22' 26 | - uses: pnpm/action-setup@v4 27 | name: Install pnpm 28 | with: 29 | version: 10.8.1 30 | run_install: false 31 | - name: Install packages 32 | run: pnpm i --ignore-scripts 33 | - name: Release check 34 | run: pnpm run ci-check 35 | - name: Setup npm auth 36 | run: | 37 | echo "//registry.npmjs.org/:_authToken=${NPM_AUTH_TOKEN}" >> ~/.npmrc 38 | npm whoami 39 | env: 40 | NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | - name: Create Release Pull Request or Publish to npm 42 | id: changesets 43 | uses: changesets/action@v1 44 | with: 45 | publish: pnpm changeset publish 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | NPM_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | /**/node_modules 4 | /.pnp 5 | .pnp.js 6 | 7 | # testing 8 | /**/coverage 9 | 10 | # distribution 11 | /**/dist 12 | 13 | # misc 14 | .DS_Store 15 | *.pem 16 | .idea 17 | 18 | # debug 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | .pnpm-debug.log* 23 | 24 | # local env files 25 | .env*.local 26 | 27 | # typescript 28 | *.tsbuildinfo 29 | 30 | # turbo 31 | .turbo 32 | 33 | # project 34 | /packages/*/types 35 | test-results -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | 2 | pnpm exec lint-staged 3 | pnpm run commit-check 4 | -------------------------------------------------------------------------------- /.npmrc : -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | save-exact=true 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["biomejs.biome"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "apps/example-next-basic - dev", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeExecutable": "pnpm", 12 | "runtimeArgs": ["dev"], 13 | "cwd": "${workspaceFolder}/apps/example-next-basic", 14 | "skipFiles": ["/**"], 15 | "outFiles": ["${workspaceFolder}/**/*.js"], 16 | "sourceMaps": true, 17 | "resolveSourceMapLocations": [ 18 | "${workspaceFolder}/apps/example-next/**", 19 | "!**/node_modules/**" 20 | ] 21 | }, 22 | { 23 | "name": "apps/example-next-basic - build", 24 | "type": "node", 25 | "request": "launch", 26 | "runtimeExecutable": "pnpm", 27 | "runtimeArgs": ["build"], 28 | "cwd": "${workspaceFolder}/apps/example-next-basic", 29 | "skipFiles": ["/**"], 30 | "outFiles": ["${workspaceFolder}/**/*.js"], 31 | "sourceMaps": true, 32 | "resolveSourceMapLocations": [ 33 | "${workspaceFolder}/apps/example-next/**", 34 | "!**/node_modules/**" 35 | ] 36 | }, 37 | { 38 | "name": "apps/example-next-basic - start", 39 | "type": "node", 40 | "request": "launch", 41 | "runtimeExecutable": "pnpm", 42 | "runtimeArgs": ["start"], 43 | "cwd": "${workspaceFolder}/apps/example-next-basic", 44 | "skipFiles": ["/**"], 45 | "outFiles": ["${workspaceFolder}/**/*.js"], 46 | "sourceMaps": true, 47 | "resolveSourceMapLocations": [ 48 | "${workspaceFolder}/apps/example-next/.next/server/**/*.js.map", 49 | "!**/node_modules/**" 50 | ] 51 | }, 52 | { 53 | "name": "apps/example-next-conform - dev", 54 | "type": "node", 55 | "request": "launch", 56 | "runtimeExecutable": "pnpm", 57 | "runtimeArgs": ["dev"], 58 | "cwd": "${workspaceFolder}/apps/example-next-conform", 59 | "skipFiles": ["/**"], 60 | "outFiles": ["${workspaceFolder}/**/*.js"], 61 | "sourceMaps": true, 62 | "resolveSourceMapLocations": [ 63 | "${workspaceFolder}/apps/example-next/**", 64 | "!**/node_modules/**" 65 | ] 66 | }, 67 | { 68 | "name": "apps/example-next-conform - build", 69 | "type": "node", 70 | "request": "launch", 71 | "runtimeExecutable": "pnpm", 72 | "runtimeArgs": ["build"], 73 | "cwd": "${workspaceFolder}/apps/example-next-conform", 74 | "skipFiles": ["/**"], 75 | "outFiles": ["${workspaceFolder}/**/*.js"], 76 | "sourceMaps": true, 77 | "resolveSourceMapLocations": [ 78 | "${workspaceFolder}/apps/example-next/**", 79 | "!**/node_modules/**" 80 | ] 81 | }, 82 | { 83 | "name": "apps/example-next-conform - start", 84 | "type": "node", 85 | "request": "launch", 86 | "runtimeExecutable": "pnpm", 87 | "runtimeArgs": ["start"], 88 | "cwd": "${workspaceFolder}/apps/example-next-conform", 89 | "skipFiles": ["/**"], 90 | "outFiles": ["${workspaceFolder}/**/*.js"], 91 | "sourceMaps": true, 92 | "resolveSourceMapLocations": [ 93 | "${workspaceFolder}/apps/example-next/.next/server/**/*.js.map", 94 | "!**/node_modules/**" 95 | ] 96 | }, 97 | { 98 | "name": "apps/example-next-custom-store - dev", 99 | "type": "node", 100 | "request": "launch", 101 | "runtimeExecutable": "pnpm", 102 | "runtimeArgs": ["dev"], 103 | "cwd": "${workspaceFolder}/apps/example-next-custom-store", 104 | "skipFiles": ["/**"], 105 | "outFiles": ["${workspaceFolder}/**/*.js"], 106 | "sourceMaps": true, 107 | "resolveSourceMapLocations": [ 108 | "${workspaceFolder}/apps/example-next/**", 109 | "!**/node_modules/**" 110 | ] 111 | }, 112 | { 113 | "name": "apps/example-next-custom-store - build", 114 | "type": "node", 115 | "request": "launch", 116 | "runtimeExecutable": "pnpm", 117 | "runtimeArgs": ["build"], 118 | "cwd": "${workspaceFolder}/apps/example-next-custom-store", 119 | "skipFiles": ["/**"], 120 | "outFiles": ["${workspaceFolder}/**/*.js"], 121 | "sourceMaps": true, 122 | "resolveSourceMapLocations": [ 123 | "${workspaceFolder}/apps/example-next/**", 124 | "!**/node_modules/**" 125 | ] 126 | }, 127 | { 128 | "name": "apps/example-next-custom-store - start", 129 | "type": "node", 130 | "request": "launch", 131 | "runtimeExecutable": "pnpm", 132 | "runtimeArgs": ["start"], 133 | "cwd": "${workspaceFolder}/apps/example-next-custom-store", 134 | "skipFiles": ["/**"], 135 | "outFiles": ["${workspaceFolder}/**/*.js"], 136 | "sourceMaps": true, 137 | "resolveSourceMapLocations": [ 138 | "${workspaceFolder}/apps/example-next/.next/server/**/*.js.map", 139 | "!**/node_modules/**" 140 | ] 141 | }, 142 | { 143 | "name": "apps/example-next-unsafe-navigation - dev", 144 | "type": "node", 145 | "request": "launch", 146 | "runtimeExecutable": "pnpm", 147 | "runtimeArgs": ["dev"], 148 | "cwd": "${workspaceFolder}/apps/example-next-unsafe-navigation", 149 | "skipFiles": ["/**"], 150 | "outFiles": ["${workspaceFolder}/**/*.js"], 151 | "sourceMaps": true, 152 | "resolveSourceMapLocations": [ 153 | "${workspaceFolder}/apps/example-next/**", 154 | "!**/node_modules/**" 155 | ] 156 | }, 157 | { 158 | "name": "apps/example-next-unsafe-navigation - build", 159 | "type": "node", 160 | "request": "launch", 161 | "runtimeExecutable": "pnpm", 162 | "runtimeArgs": ["build"], 163 | "cwd": "${workspaceFolder}/apps/example-next-unsafe-navigation", 164 | "skipFiles": ["/**"], 165 | "outFiles": ["${workspaceFolder}/**/*.js"], 166 | "sourceMaps": true, 167 | "resolveSourceMapLocations": [ 168 | "${workspaceFolder}/apps/example-next/**", 169 | "!**/node_modules/**" 170 | ] 171 | }, 172 | { 173 | "name": "apps/example-next-unsafe-navigation - start", 174 | "type": "node", 175 | "request": "launch", 176 | "runtimeExecutable": "pnpm", 177 | "runtimeArgs": ["start"], 178 | "cwd": "${workspaceFolder}/apps/example-next-unsafe-navigation", 179 | "skipFiles": ["/**"], 180 | "outFiles": ["${workspaceFolder}/**/*.js"], 181 | "sourceMaps": true, 182 | "resolveSourceMapLocations": [ 183 | "${workspaceFolder}/apps/example-next/.next/server/**/*.js.map", 184 | "!**/node_modules/**" 185 | ] 186 | }, 187 | ] 188 | } 189 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "editor.formatOnSave": true, 4 | "[typescript]": { 5 | "editor.defaultFormatter": "biomejs.biome" 6 | }, 7 | "[javascript]": { 8 | "editor.defaultFormatter": "biomejs.biome" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # location-state 2 | 3 | [![npm version](https://badge.fury.io/js/@location-state%2Fcore.svg)](https://badge.fury.io/js/@location-state%2Fcore) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | 6 | State management library for React that synchronizes with history location supporting Next.js App Router. 7 | 8 | ## Features 9 | 10 | - Manage the state to synchronize with the history location. 11 | - By default, supports Session Storage and URL as persistent destinations. 12 | 13 | ## Packages 14 | 15 | - [@location-state/core](./packages/location-state-core/README.md): Framework agnostic, but for Next.js App Router. 16 | - [@location-state/next](./packages/location-state-next/README.md): For Next.js Pages Router. 17 | - [@location-state/conform](./packages/location-state-conform/README.md): For conform. 18 | 19 | ## Quickstart for Next.js [App Router](https://nextjs.org/docs/app) 20 | 21 | ### Installation 22 | 23 | ```sh 24 | npm install @location-state/core 25 | # or 26 | yarn add @location-state/core 27 | # or 28 | pnpm add @location-state/core 29 | ``` 30 | 31 | ### Configuration 32 | 33 | ```tsx 34 | // src/app/Providers.tsx 35 | "use client"; 36 | 37 | import { LocationStateProvider } from "@location-state/core"; 38 | 39 | export function Providers({ children }: { children: React.ReactNode }) { 40 | return {children}; 41 | } 42 | ``` 43 | 44 | ```tsx 45 | // src/app/layout.tsx 46 | import { Providers } from "./Providers"; 47 | 48 | // ...snip... 49 | 50 | export default function RootLayout({ 51 | children, 52 | }: { 53 | children: React.ReactNode; 54 | }) { 55 | return ( 56 | 57 | 58 | {children} 59 | 60 | 61 | ); 62 | } 63 | ``` 64 | 65 | ### Working with state 66 | 67 | ```tsx 68 | "use client"; 69 | 70 | import { useLocationState } from "@location-state/core"; 71 | 72 | export function Counter() { 73 | const [counter, setCounter] = useLocationState({ 74 | name: "counter", 75 | defaultValue: 0, 76 | storeName: "session", 77 | }); 78 | 79 | return ( 80 |
81 |

82 | storeName: {storeName}, counter: {counter} 83 |

84 | 85 |
86 | ); 87 | } 88 | ``` 89 | 90 | ## Quickstart for [Page Router](https://nextjs.org/docs/pages) 91 | 92 | ### Installation 93 | 94 | ```sh 95 | npm install @location-state/core @location-state/next 96 | # or 97 | yarn add @location-state/core @location-state/next 98 | # or 99 | pnpm add @location-state/core @location-state/next 100 | ``` 101 | 102 | ### Configuration 103 | 104 | ```tsx 105 | // src/pages/_app.tsx 106 | import { LocationStateProvider } from "@location-state/core"; 107 | import { useNextPagesSyncer } from "@location-state/next"; 108 | import type { AppProps } from "next/app"; 109 | 110 | export default function MyApp({ Component, pageProps }: AppProps) { 111 | const syncer = useNextPagesSyncer(); 112 | return ( 113 | 114 | 115 | 116 | ); 117 | } 118 | ``` 119 | 120 | ### Working with state 121 | 122 | ```tsx 123 | import { useLocationState } from "@location-state/core"; 124 | 125 | export function Counter() { 126 | const [counter, setCounter] = useLocationState({ 127 | name: "counter", 128 | defaultValue: 0, 129 | storeName: "session", 130 | }); 131 | 132 | return ( 133 |
134 |

135 | storeName: {storeName}, counter: {counter} 136 |

137 | 138 |
139 | ); 140 | } 141 | ``` 142 | -------------------------------------------------------------------------------- /apps/example-next-basic/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # playwright 38 | playwright-report 39 | -------------------------------------------------------------------------------- /apps/example-next-basic/README.md: -------------------------------------------------------------------------------- 1 | # Examples next app 2 | 3 | ## Getting Started 4 | 5 | ``` 6 | $ pnpm i 7 | $ pnpm run dev 8 | ``` 9 | -------------------------------------------------------------------------------- /apps/example-next-basic/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | scrollRestoration: true, 5 | }, 6 | 7 | productionBrowserSourceMaps: true, 8 | webpack: (config, { isServer }) => { 9 | if (isServer) { 10 | config.devtool = "source-map"; 11 | } 12 | return config; 13 | }, 14 | }; 15 | 16 | module.exports = nextConfig; 17 | -------------------------------------------------------------------------------- /apps/example-next-basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-next-basic", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "integration-test": "playwright test", 10 | "integration-test:ui": "playwright test --ui" 11 | }, 12 | "dependencies": { 13 | "@location-state/core": "workspace:*", 14 | "@location-state/next": "workspace:*", 15 | "@types/node": "22.15.29", 16 | "@types/react": "19.1.5", 17 | "@types/react-dom": "19.1.5", 18 | "next": "15.3.3", 19 | "react": "19.1.0", 20 | "react-dom": "19.1.0", 21 | "typescript": "5.8.2", 22 | "zod": "3.25.28" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/example-next-basic/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | export default defineConfig({ 4 | // Look for test files in the "tests" directory, relative to this configuration file. 5 | testDir: "playwright", 6 | // Run all tests in parallel. 7 | fullyParallel: true, // Fail the build on CI if you accidentally left test.only in the source code. 8 | forbidOnly: !!process.env.CI, // Retry on CI only. 9 | retries: process.env.CI ? 2 : 0, // Opt out of parallel tests on CI. 10 | workers: process.env.CI ? 1 : undefined, // Reporter to use 11 | reporter: "line", 12 | use: { 13 | // Base URL to use in actions like `await page.goto('/')`. 14 | baseURL: "http://127.0.0.1:3000", // Collect trace when retrying the failed test. 15 | trace: "on-first-retry", 16 | }, 17 | // Configure projects for major browsers. 18 | projects: [ 19 | { 20 | name: "chromium", 21 | use: { ...devices["Desktop Chrome"] }, 22 | }, 23 | ], 24 | // Run your local dev server before starting the tests. 25 | webServer: { 26 | command: "pnpm build && pnpm start", 27 | url: "http://127.0.0.1:3000", 28 | reuseExistingServer: !process.env.CI, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /apps/example-next-basic/playwright/save-on-use-effect.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | 3 | test("restore random id that save on `useEffect()`", async ({ page }) => { 4 | // Navigate to the target page. 5 | await page.goto("http://localhost:3000/pages/save-on-use-effect"); 6 | const h1Element = page.getByRole("heading", { level: 1 }); 7 | const initialH1Text = await h1Element.textContent(); 8 | await expect(initialH1Text).not.toBeNull(); 9 | await expect(h1Element).toHaveText( 10 | /Save on `useEffect\(\)`, random value: \d+/, 11 | ); 12 | // Navigate to the top page. 13 | await page.getByRole("link", { name: /^\/pages$/ }).click(); 14 | await expect(page).toHaveURL("http://localhost:3000/pages"); 15 | // Navigate back to the target page. 16 | await page.goBack(); 17 | await expect(page).toHaveURL( 18 | "http://localhost:3000/pages/save-on-use-effect", 19 | ); 20 | await expect( 21 | await page.getByRole("heading", { level: 1 }).textContent(), 22 | ).toBe(initialH1Text); 23 | }); 24 | -------------------------------------------------------------------------------- /apps/example-next-basic/playwright/session-counter.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { expect, test } from "@playwright/test"; 3 | 4 | describe('"session counter" is restored on browser back.', () => { 5 | describe("In app router", () => { 6 | [ 7 | ["http://127.0.0.1:3000/static"], 8 | ["http://127.0.0.1:3000/dynamic"], 9 | ].forEach(([url]) => { 10 | test(`browser back on "page: ${url}`, async ({ page }) => { 11 | // Navigate to the target page. 12 | await page.goto(url); 13 | const sessionRegion = page.getByRole("region", { 14 | name: "session store counter", 15 | }); 16 | // Default counter is 0. 17 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 18 | "counter: 0", 19 | ); 20 | // Click the `session increment` button. 21 | await sessionRegion.getByRole("button", { name: "increment" }).click(); 22 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 23 | "counter: 1", 24 | ); 25 | // Navigate to the top page. 26 | await page.getByRole("link", { name: "top" }).click(); 27 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 28 | "counter: 0", 29 | ); 30 | // Navigate back to the target page. 31 | await page.goBack(); 32 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 33 | "counter: 1", 34 | ); 35 | }); 36 | }); 37 | }); 38 | 39 | describe("In pages router", () => { 40 | [ 41 | ["http://127.0.0.1:3000/pages/other"], 42 | ["http://127.0.0.1:3000/pages/ssr/1"], 43 | ["http://127.0.0.1:3000/pages/ssg/1"], 44 | ].forEach(([url]) => { 45 | test(`browser back on page: ${url}`, async ({ page }) => { 46 | // Navigate to the target page. 47 | await page.goto(url); 48 | const sessionRegion = page.getByRole("region", { 49 | name: "session store counter", 50 | }); 51 | // Default counter is 0. 52 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 53 | "counter: 0", 54 | ); 55 | // Click the `session increment` button. 56 | await sessionRegion.getByRole("button", { name: "increment" }).click(); 57 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 58 | "counter: 1", 59 | ); 60 | // Navigate to the top page. 61 | await page.getByRole("link", { name: /^\/pages$/ }).click(); 62 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 63 | "counter: 0", 64 | ); 65 | // Navigate back to the target page. 66 | await page.goBack(); 67 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 68 | "counter: 1", 69 | ); 70 | }); 71 | }); 72 | }); 73 | }); 74 | 75 | describe('"session counter" is restored on reload.', () => { 76 | describe("In app router", () => { 77 | [ 78 | ["http://127.0.0.1:3000/static"], 79 | ["http://127.0.0.1:3000/dynamic"], 80 | ].forEach(([url]) => { 81 | test(`reload on "page: ${url}`, async ({ page }) => { 82 | // Navigate to the target page. 83 | await page.goto(url); 84 | const sessionRegion = page.getByRole("region", { 85 | name: "session store counter", 86 | }); 87 | // Default counter is 0. 88 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 89 | "counter: 0", 90 | ); 91 | // Click the `session increment` button. 92 | await sessionRegion.getByRole("button", { name: "increment" }).click(); 93 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 94 | "counter: 1", 95 | ); 96 | // reload the page. 97 | await page.reload(); 98 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 99 | "counter: 1", 100 | ); 101 | }); 102 | }); 103 | }); 104 | 105 | describe("In pages router", () => { 106 | [ 107 | ["http://127.0.0.1:3000/pages/other"], 108 | ["http://127.0.0.1:3000/pages/ssr/1"], 109 | ["http://127.0.0.1:3000/pages/ssg/1"], 110 | ].forEach(([url]) => { 111 | test(`reload on page: ${url}`, async ({ page }) => { 112 | // Navigate to the target page. 113 | await page.goto(url); 114 | const sessionRegion = page.getByRole("region", { 115 | name: "session store counter", 116 | }); 117 | // Default counter is 0. 118 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 119 | "counter: 0", 120 | ); 121 | // Click the `session increment` button. 122 | await sessionRegion.getByRole("button", { name: "increment" }).click(); 123 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 124 | "counter: 1", 125 | ); 126 | // reload the page. 127 | await page.reload(); 128 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 129 | "counter: 1", 130 | ); 131 | }); 132 | }); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /apps/example-next-basic/playwright/session-list.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { expect, test } from "@playwright/test"; 3 | 4 | describe('"session list" is restored on browser back.', () => { 5 | describe("In app router", () => { 6 | [ 7 | ["http://127.0.0.1:3000/static"], 8 | ["http://127.0.0.1:3000/dynamic"], 9 | ].forEach(([url]) => { 10 | test(`browser back on "page: ${url}`, async ({ page }) => { 11 | // Navigate to the target page. 12 | await page.goto(url); 13 | const sessionRegion = page.getByRole("region", { 14 | name: "session store list", 15 | }); 16 | // Default list is empty. 17 | await expect(sessionRegion.getByRole("list")).toHaveCount(0); 18 | // Click the `session increment` button. 19 | await sessionRegion 20 | .getByRole("checkbox", { name: "Display List" }) 21 | .click(); 22 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 23 | // Navigate to the top page. 24 | await page.getByRole("link", { name: "top" }).click(); 25 | await expect(sessionRegion.getByRole("list")).toHaveCount(0); 26 | // Navigate back to the target page. 27 | await page.goBack(); 28 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 29 | }); 30 | }); 31 | }); 32 | 33 | describe("In pages router", () => { 34 | [ 35 | ["http://127.0.0.1:3000/pages/other"], 36 | ["http://127.0.0.1:3000/pages/ssr/1"], 37 | ["http://127.0.0.1:3000/pages/ssg/1"], 38 | ].forEach(([url]) => { 39 | test(`browser back on page: ${url}`, async ({ page }) => { 40 | // Navigate to the target page. 41 | await page.goto(url); 42 | const sessionRegion = page.getByRole("region", { 43 | name: "session store list", 44 | }); 45 | // Default list is empty. 46 | await expect(sessionRegion.getByRole("list")).toHaveCount(0); 47 | // Click the `session increment` button. 48 | await sessionRegion 49 | .getByRole("checkbox", { name: "Display List" }) 50 | .click(); 51 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 52 | // Navigate to the top page. 53 | await page.getByRole("link", { name: /^\/pages$/ }).click(); 54 | await expect(sessionRegion.getByRole("list")).toHaveCount(0); 55 | // Navigate back to the target page. 56 | await page.goBack(); 57 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 58 | }); 59 | }); 60 | }); 61 | }); 62 | 63 | describe('"session list" is restored on reload.', () => { 64 | describe("In app router", () => { 65 | [ 66 | ["http://127.0.0.1:3000/static"], 67 | ["http://127.0.0.1:3000/dynamic"], 68 | ].forEach(([url]) => { 69 | test(`reload on "page: ${url}`, async ({ page }) => { 70 | // Navigate to the target page. 71 | await page.goto(url); 72 | const sessionRegion = page.getByRole("region", { 73 | name: "session store list", 74 | }); 75 | // Default list is empty. 76 | await expect(sessionRegion.getByRole("list")).toHaveCount(0); 77 | // Click the `session increment` button. 78 | await sessionRegion 79 | .getByRole("checkbox", { name: "Display List" }) 80 | .click(); 81 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 82 | // Navigate back to the target page. 83 | await page.reload(); 84 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 85 | }); 86 | }); 87 | }); 88 | 89 | describe("In pages router", () => { 90 | [ 91 | ["http://127.0.0.1:3000/pages/other"], 92 | ["http://127.0.0.1:3000/pages/ssr/1"], 93 | ["http://127.0.0.1:3000/pages/ssg/1"], 94 | ].forEach(([url]) => { 95 | test(`reload on page: ${url}`, async ({ page }) => { 96 | // Navigate to the target page. 97 | await page.goto(url); 98 | const sessionRegion = page.getByRole("region", { 99 | name: "session store list", 100 | }); 101 | // Default list is empty. 102 | await expect(sessionRegion.getByRole("list")).toHaveCount(0); 103 | // Click the `session increment` button. 104 | await sessionRegion 105 | .getByRole("checkbox", { name: "Display List" }) 106 | .click(); 107 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 108 | // Navigate back to the target page. 109 | await page.reload(); 110 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 111 | }); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /apps/example-next-basic/playwright/url-counter.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { expect, test } from "@playwright/test"; 3 | 4 | describe('"url counter" is restored on browser back.', () => { 5 | describe("In app router", () => { 6 | [ 7 | ["http://127.0.0.1:3000/static"], 8 | ["http://127.0.0.1:3000/dynamic"], 9 | ].forEach(([url]) => { 10 | test(`browser back on "page: ${url}`, async ({ page }) => { 11 | // Navigate to the target page. 12 | await page.goto(url); 13 | const urlRegion = page.getByRole("region", { 14 | name: "url store counter", 15 | }); 16 | // Default counter is 0. 17 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 0"); 18 | // Click the `url increment` button. 19 | await urlRegion.getByRole("button", { name: "increment" }).click(); 20 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 21 | // Navigate to the top page. 22 | await page.getByRole("link", { name: "top" }).click(); 23 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 0"); 24 | // Navigate back to the target page. 25 | await page.goBack(); 26 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 27 | expect(page.url()).toBe( 28 | `${url}?location-state=%7B%22counter%22%3A1%7D`, 29 | ); 30 | }); 31 | }); 32 | }); 33 | 34 | describe("In pages router", () => { 35 | [ 36 | ["http://127.0.0.1:3000/pages/other"], 37 | ["http://127.0.0.1:3000/pages/ssr/1"], 38 | ["http://127.0.0.1:3000/pages/ssg/1"], 39 | ].forEach(([url]) => { 40 | test(`browser back on page: ${url}`, async ({ page }) => { 41 | // Navigate to the target page. 42 | await page.goto(url); 43 | const urlRegion = page.getByRole("region", { 44 | name: "url store counter", 45 | }); 46 | // Default counter is 0. 47 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 0"); 48 | // Click the `url increment` button. 49 | await urlRegion.getByRole("button", { name: "increment" }).click(); 50 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 51 | // Navigate to the top page. 52 | await page.getByRole("link", { name: /^\/pages$/ }).click(); 53 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 0"); 54 | // Navigate back to the target page. 55 | await page.goBack(); 56 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 57 | expect(page.url()).toBe( 58 | `${url}?location-state=%7B%22counter%22%3A1%7D`, 59 | ); 60 | }); 61 | }); 62 | }); 63 | }); 64 | 65 | describe('"url counter" is restored on reload.', () => { 66 | describe("In app router", () => { 67 | [ 68 | ["http://127.0.0.1:3000/static"], 69 | ["http://127.0.0.1:3000/dynamic"], 70 | ].forEach(([url]) => { 71 | test(`reload on "page: ${url}`, async ({ page }) => { 72 | // Navigate to the target page. 73 | await page.goto(url); 74 | const urlRegion = page.getByRole("region", { 75 | name: "url store counter", 76 | }); 77 | // Default counter is 0. 78 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 0"); 79 | // Click the `url increment` button. 80 | await urlRegion.getByRole("button", { name: "increment" }).click(); 81 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 82 | // reload the page. 83 | await page.reload(); 84 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 85 | expect(page.url()).toBe( 86 | `${url}?location-state=%7B%22counter%22%3A1%7D`, 87 | ); 88 | }); 89 | }); 90 | }); 91 | 92 | describe("In pages router", () => { 93 | [ 94 | ["http://127.0.0.1:3000/pages/other"], 95 | ["http://127.0.0.1:3000/pages/ssr/1"], 96 | ["http://127.0.0.1:3000/pages/ssg/1"], 97 | ].forEach(([url]) => { 98 | test(`reload on page: ${url}`, async ({ page }) => { 99 | // Navigate to the target page. 100 | await page.goto(url); 101 | const urlRegion = page.getByRole("region", { 102 | name: "url store counter", 103 | }); 104 | // Default counter is 0. 105 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 0"); 106 | // Click the `url increment` button. 107 | await urlRegion.getByRole("button", { name: "increment" }).click(); 108 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 109 | // reload the page. 110 | await page.reload(); 111 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 112 | expect(page.url()).toBe( 113 | `${url}?location-state=%7B%22counter%22%3A1%7D`, 114 | ); 115 | }); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /apps/example-next-basic/playwright/url-list.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { expect, test } from "@playwright/test"; 3 | 4 | describe('"url list" is restored on browser back.', () => { 5 | describe("In app router", () => { 6 | [ 7 | ["http://127.0.0.1:3000/static"], 8 | ["http://127.0.0.1:3000/dynamic"], 9 | ].forEach(([url]) => { 10 | test(`browser back on "page: ${url}`, async ({ page }) => { 11 | // Navigate to the target page. 12 | await page.goto(url); 13 | const urlRegion = page.getByRole("region", { 14 | name: "url store list", 15 | }); 16 | // Default list is empty. 17 | await expect(urlRegion.getByRole("list")).toHaveCount(0); 18 | // Click the `url increment` button. 19 | await urlRegion.getByRole("checkbox", { name: "Display List" }).click(); 20 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 21 | // Navigate to the top page. 22 | await page.getByRole("link", { name: "top" }).click(); 23 | await expect(urlRegion.getByRole("list")).toHaveCount(0); 24 | // Navigate back to the target page. 25 | await page.goBack(); 26 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 27 | expect(page.url()).toBe( 28 | `${url}?location-state=%7B%22display-list%22%3Atrue%7D`, 29 | ); 30 | }); 31 | }); 32 | }); 33 | 34 | describe("In pages router", () => { 35 | [ 36 | ["http://127.0.0.1:3000/pages/other"], 37 | ["http://127.0.0.1:3000/pages/ssr/1"], 38 | ["http://127.0.0.1:3000/pages/ssg/1"], 39 | ].forEach(([url]) => { 40 | test(`browser back on page: ${url}`, async ({ page }) => { 41 | // Navigate to the target page. 42 | await page.goto(url); 43 | const urlRegion = page.getByRole("region", { 44 | name: "url store list", 45 | }); 46 | // Default list is empty. 47 | await expect(urlRegion.getByRole("list")).toHaveCount(0); 48 | // Click the `url increment` button. 49 | await urlRegion.getByRole("checkbox", { name: "Display List" }).click(); 50 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 51 | // Navigate to the top page. 52 | await page.getByRole("link", { name: /^\/pages$/ }).click(); 53 | await expect(urlRegion.getByRole("list")).toHaveCount(0); 54 | // Navigate back to the target page. 55 | await page.goBack(); 56 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 57 | expect(page.url()).toBe( 58 | `${url}?location-state=%7B%22display-list%22%3Atrue%7D`, 59 | ); 60 | }); 61 | }); 62 | }); 63 | }); 64 | 65 | describe('"url list" is restored on reload.', () => { 66 | describe("In app router", () => { 67 | [ 68 | ["http://127.0.0.1:3000/static"], 69 | ["http://127.0.0.1:3000/dynamic"], 70 | ].forEach(([url]) => { 71 | test(`reload on "page: ${url}`, async ({ page }) => { 72 | // Navigate to the target page. 73 | await page.goto(url); 74 | const urlRegion = page.getByRole("region", { 75 | name: "url store list", 76 | }); 77 | // Default list is empty. 78 | await expect(urlRegion.getByRole("list")).toHaveCount(0); 79 | // Click the `url increment` button. 80 | await urlRegion.getByRole("checkbox", { name: "Display List" }).click(); 81 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 82 | // Navigate back to the target page. 83 | await page.reload(); 84 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 85 | expect(page.url()).toBe( 86 | `${url}?location-state=%7B%22display-list%22%3Atrue%7D`, 87 | ); 88 | }); 89 | }); 90 | }); 91 | 92 | describe("In pages router", () => { 93 | [ 94 | ["http://127.0.0.1:3000/pages/other"], 95 | ["http://127.0.0.1:3000/pages/ssr/1"], 96 | ["http://127.0.0.1:3000/pages/ssg/1"], 97 | ].forEach(([url]) => { 98 | test(`reload on page: ${url}`, async ({ page }) => { 99 | // Navigate to the target page. 100 | await page.goto(url); 101 | const urlRegion = page.getByRole("region", { 102 | name: "url store list", 103 | }); 104 | // Default list is empty. 105 | await expect(urlRegion.getByRole("list")).toHaveCount(0); 106 | // Click the `url increment` button. 107 | await urlRegion.getByRole("checkbox", { name: "Display List" }).click(); 108 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 109 | // Navigate back to the target page. 110 | await page.reload(); 111 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 112 | expect(page.url()).toBe( 113 | `${url}?location-state=%7B%22display-list%22%3Atrue%7D`, 114 | ); 115 | }); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/app/_components/Providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LocationStateProvider } from "@location-state/core"; 4 | 5 | export function Providers({ children }: { children: React.ReactNode }) { 6 | return {children}; 7 | } 8 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/app/dynamic/page.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import { headers } from "next/headers"; 4 | import Link from "next/link"; 5 | 6 | export default async function Page() { 7 | const headersList = await headers(); 8 | const referer = headersList.get("referer"); 9 | 10 | return ( 11 |
12 |

Dynamic page

13 | /(top) 14 |

referer: {referer}

15 | 16 | 17 | 18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Providers } from "@/app/_components/Providers"; 2 | import type { Metadata } from "next"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Create Next App", 6 | description: "Generated by create next app", 7 | }; 8 | 9 | export default function RootLayout({ 10 | children, 11 | }: { 12 | children: React.ReactNode; 13 | }) { 14 | return ( 15 | 16 | 17 | {children} 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import Link from "next/link"; 4 | 5 | export default function Page() { 6 | return ( 7 |
8 |

Top page

9 |
    10 |
  • 11 | /static 12 |
  • 13 |
  • 14 | /dynamic 15 |
  • 16 |
  • 17 | /pages 18 |
  • 19 |
20 | 21 | 22 | 23 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/app/static/page.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import Link from "next/link"; 4 | 5 | export default function Page() { 6 | return ( 7 |
8 |

Static page

9 | /(top) 10 | 11 | 12 | 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/components/Counter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | type DefaultStoreName, 5 | type Refine, 6 | useLocationState, 7 | } from "@location-state/core"; 8 | import { useId } from "react"; 9 | import { type ZodType, z } from "zod"; 10 | 11 | const zodRefine = 12 | (schema: ZodType): Refine => 13 | (value) => { 14 | const result = schema.safeParse(value); 15 | return result.success ? result.data : undefined; 16 | }; 17 | 18 | export function Counter({ storeName }: { storeName: DefaultStoreName }) { 19 | const [counter, setCounter] = useLocationState({ 20 | name: "counter", 21 | defaultValue: 0, 22 | storeName, 23 | refine: zodRefine(z.number()), 24 | }); 25 | console.debug("rendered Counter", { storeName, counter }); 26 | 27 | const sectionId = useId(); 28 | 29 | return ( 30 |
31 |

{storeName} store counter

32 |

33 | counter: {counter} 34 |

35 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/components/List.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type DefaultStoreName, useLocationState } from "@location-state/core"; 4 | import { useId } from "react"; 5 | 6 | export function List({ storeName }: { storeName: DefaultStoreName }) { 7 | const [displayList, setDisplayList] = useLocationState({ 8 | name: "display-list", 9 | defaultValue: false, 10 | storeName, 11 | }); 12 | const list = Array(100).fill(0); 13 | console.debug("rendered List", { storeName, displayList }); 14 | 15 | const sectionId = useId(); 16 | const accordionId = useId(); 17 | 18 | return ( 19 |
20 |

{storeName} store list

21 | 31 |
    37 | {list.map((_, index) => ( 38 | // biome-ignore lint: noArrayIndexKey 39 |
  • {index}
  • 40 | ))} 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { LocationStateProvider } from "@location-state/core"; 2 | import { useNextPagesSyncer } from "@location-state/next"; 3 | import type { AppProps } from "next/app"; 4 | 5 | export default function MyApp({ Component, pageProps }: AppProps) { 6 | const syncer = useNextPagesSyncer(); 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/pages/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import Link from "next/link"; 4 | 5 | export default function Page() { 6 | return ( 7 |
8 |

Page

9 |
    10 |
  • 11 | /pages/other 12 |
  • 13 |
  • 14 | /pages/ssr/1 15 |
  • 16 |
  • 17 | /pages/ssg/1 18 |
  • 19 |
  • 20 | 21 | /pages/save-on-use-effect 22 | 23 |
  • 24 |
25 | 26 | 27 | 28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/pages/pages/other.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import Link from "next/link"; 4 | 5 | export default function Page() { 6 | return ( 7 |
8 |

Other Page

9 |

10 | /pages 11 |

12 | 13 | 14 | 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/pages/pages/save-on-use-effect/index.tsx: -------------------------------------------------------------------------------- 1 | import { useLocationState } from "@location-state/core"; 2 | import type { GetServerSideProps } from "next"; 3 | import Link from "next/link"; 4 | import { useEffect } from "react"; 5 | 6 | type Props = { 7 | id: number; 8 | }; 9 | 10 | export default function Page({ id }: Props) { 11 | const [randomValue, setRandomValue] = useLocationState({ 12 | name: "randomValue", 13 | defaultValue: null, 14 | storeName: "session", 15 | }); 16 | 17 | useEffect(() => { 18 | if (randomValue === null) { 19 | setRandomValue(id); 20 | console.debug("set randomValue: ", id); 21 | } 22 | }, [randomValue, setRandomValue, id]); 23 | 24 | return ( 25 |
26 |

Save on `useEffect()`, random value: {randomValue}

27 |
    28 |
  • 29 | /pages 30 |
  • 31 |
  • 32 | /pages/other 33 |
  • 34 |
35 |
36 | ); 37 | } 38 | 39 | export const getServerSideProps: GetServerSideProps< 40 | Props, 41 | { id: string } 42 | > = async () => { 43 | return { 44 | props: { 45 | id: Math.floor(Math.random() * 100), 46 | }, 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/pages/pages/ssg/[id].tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import type { GetServerSideProps, GetStaticPaths } from "next"; 4 | import Link from "next/link"; 5 | 6 | type Props = { 7 | id: number; 8 | }; 9 | 10 | export default function Page({ id }: Props) { 11 | const nextUrl = `/pages/ssg/${id + 1}`; 12 | return ( 13 |
14 |

SSG Page

15 |

id: {id}

16 |

17 | /pages 18 |

19 |

20 | {nextUrl} 21 |

22 | 23 | 24 | 25 | 26 |
27 | ); 28 | } 29 | 30 | export const getStaticPaths: GetStaticPaths<{ id: string }> = async () => { 31 | return { 32 | paths: Array.from({ length: 10 }, (_, i) => ({ 33 | params: { id: String(i) }, 34 | })), 35 | fallback: false, 36 | }; 37 | }; 38 | 39 | export const getStaticProps: GetServerSideProps< 40 | Props, 41 | { id: string } 42 | > = async ({ params }) => { 43 | return { 44 | props: { 45 | id: Number(params?.id), 46 | }, 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /apps/example-next-basic/src/pages/pages/ssr/[id].tsx: -------------------------------------------------------------------------------- 1 | import { setTimeout } from "node:timers/promises"; 2 | import { Counter } from "@/components/Counter"; 3 | import { List } from "@/components/List"; 4 | import type { GetServerSideProps } from "next"; 5 | import Link from "next/link"; 6 | 7 | type Props = { 8 | id: number; 9 | }; 10 | 11 | export default function Page({ id }: Props) { 12 | const nextUrl = `/pages/ssr/${id + 1}`; 13 | return ( 14 |
15 |

SSR Page

16 |

id: {id}

17 |

18 | /pages 19 |

20 |

21 | {nextUrl} 22 |

23 | 24 | 25 | 26 | 27 |
28 | ); 29 | } 30 | 31 | export const getServerSideProps: GetServerSideProps< 32 | Props, 33 | { id: string } 34 | > = async ({ params }) => { 35 | await setTimeout(1000); 36 | return { 37 | props: { 38 | id: Number(params?.id), 39 | }, 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /apps/example-next-basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /apps/example-next-conform/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | /test-results/ 38 | /playwright-report/ 39 | /blob-report/ 40 | /playwright/.cache/ 41 | -------------------------------------------------------------------------------- /apps/example-next-conform/README.md: -------------------------------------------------------------------------------- 1 | # Examples next app with conform 2 | 3 | ## Getting Started 4 | 5 | ``` 6 | $ pnpm i 7 | $ pnpm run dev 8 | ``` 9 | 10 | ## Conform 11 | 12 | https://conform.guide/integration/nextjs 13 | -------------------------------------------------------------------------------- /apps/example-next-conform/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | scrollRestoration: true, 5 | }, 6 | productionBrowserSourceMaps: true, 7 | webpack: (config, { isServer }) => { 8 | if (isServer) { 9 | config.devtool = "source-map"; 10 | } 11 | return config; 12 | }, 13 | }; 14 | 15 | module.exports = nextConfig; 16 | -------------------------------------------------------------------------------- /apps/example-next-conform/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-next-conform", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbo", 7 | "build": "next build", 8 | "start": "next start", 9 | "clean-start": "rm -rf .next && next build && next start", 10 | "integration-test": "playwright test", 11 | "integration-test:ui": "playwright test --ui" 12 | }, 13 | "dependencies": { 14 | "@conform-to/react": "1.6.1", 15 | "@conform-to/zod": "1.6.1", 16 | "@location-state/core": "workspace:*", 17 | "@location-state/conform": "workspace:*", 18 | "next": "15.3.3", 19 | "react": "19.1.0", 20 | "react-dom": "19.1.0", 21 | "zod": "3.25.28" 22 | }, 23 | "devDependencies": { 24 | "@testing-library/jest-dom": "6.6.3", 25 | "@types/node": "22.15.29", 26 | "@types/react": "19.1.5", 27 | "@types/react-dom": "19.1.5", 28 | "typescript": "5.8.2" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/example-next-conform/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | export default defineConfig({ 4 | // Look for test files in the "tests" directory, relative to this configuration file. 5 | testDir: "playwright", 6 | // Run all tests in parallel. 7 | fullyParallel: true, // Fail the build on CI if you accidentally left test.only in the source code. 8 | forbidOnly: !!process.env.CI, // Retry on CI only. 9 | retries: process.env.CI ? 2 : 0, // Opt out of parallel tests on CI. 10 | workers: process.env.CI ? 1 : undefined, // Reporter to use 11 | reporter: "line", 12 | use: { 13 | // Base URL to use in actions like `await page.goto('/')`. 14 | baseURL: "http://127.0.0.1:3000", // Collect trace when retrying the failed test. 15 | trace: "on-first-retry", 16 | }, 17 | // Configure projects for major browsers. 18 | projects: [ 19 | { 20 | name: "chromium", 21 | use: { ...devices["Desktop Chrome"] }, 22 | }, 23 | ], 24 | // Run your local dev server before starting the tests. 25 | webServer: { 26 | command: "pnpm build && pnpm start", 27 | url: "http://127.0.0.1:3000", 28 | reuseExistingServer: !process.env.CI, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /apps/example-next-conform/playwright/session-form.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { expect, test } from "@playwright/test"; 3 | 4 | // Safari/Firefox: different from actual behavior 5 | test.skip(({ browserName }) => browserName !== "chromium", "Chromium only!"); 6 | 7 | describe('"simple form" is restored on browser back.', () => { 8 | test("browser back on simple form", async ({ page }) => { 9 | // Navigate to the target page. 10 | await page.goto("http://localhost:3000/forms/session/simple-form"); 11 | const firstName = page.getByRole("textbox", { name: "First name" }); 12 | const lastName = page.getByRole("textbox", { name: "Last name" }); 13 | // Default value is "". 14 | await expect(firstName).toHaveValue(""); 15 | await expect(lastName).toHaveValue(""); 16 | // Change `firstName` & `lastName`. 17 | await firstName.fill("Tanaka"); 18 | await lastName.fill("Taro"); 19 | // Navigate to the top page. 20 | await page.goto("http://localhost:3000"); 21 | await expect(page.getByRole("heading")).toHaveText("`conform` example"); 22 | // Navigate back to the target page. 23 | await page.goBack(); 24 | await expect(firstName).toHaveValue("Tanaka"); 25 | await expect(lastName).toHaveValue("Taro"); 26 | }); 27 | }); 28 | 29 | describe('"dynamic form" is restored on browser back.', () => { 30 | test("browser back on dynamic form", async ({ page }) => { 31 | // Navigate to the target page. 32 | await page.goto("http://localhost:3000/forms/session/dynamic-form"); 33 | // Add 2 member fields. 34 | const addMember = page.getByRole("button", { name: "Add member to last" }); 35 | await addMember.click(); 36 | await addMember.click(); 37 | // Default value is "". 38 | const member1Name = page.getByRole("textbox", { name: "name: " }).first(); 39 | const member2Name = page.getByRole("textbox", { name: "name: " }).nth(1); 40 | await expect(member1Name).toHaveValue(""); 41 | await expect(member2Name).toHaveValue(""); 42 | // Change `firstName` & `lastName`. 43 | await member1Name.fill("Tanaka"); 44 | // Navigate to the top page. 45 | await page.goto("http://localhost:3000"); 46 | await expect(page.getByRole("heading")).toHaveText("`conform` example"); 47 | // Navigate back to the target page. 48 | await page.goBack(); 49 | await expect(member1Name).toHaveValue("Tanaka"); 50 | await expect(member2Name).toHaveValue(""); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /apps/example-next-conform/playwright/url-form.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { setTimeout } from "node:timers/promises"; 3 | import { expect, test } from "@playwright/test"; 4 | 5 | // Safari/Firefox: different from actual behavior 6 | test.skip(({ browserName }) => browserName !== "chromium", "Chromium only!"); 7 | 8 | describe('"simple form" is restored on browser back.', () => { 9 | test("browser back on simple form", async ({ page }) => { 10 | // Navigate to the target page. 11 | await page.goto("http://localhost:3000/forms/url/simple-form"); 12 | const firstName = page.getByRole("textbox", { name: "First name" }); 13 | const lastName = page.getByRole("textbox", { name: "Last name" }); 14 | // Default value is "". 15 | await expect(firstName).toHaveValue(""); 16 | await expect(lastName).toHaveValue(""); 17 | // Change `firstName` & `lastName`. 18 | await firstName.fill("Tanaka"); 19 | await lastName.fill("Taro"); 20 | await setTimeout(1000); // Wait for applying the value to URL. 21 | // Navigate to the top page. 22 | await page.goto("http://localhost:3000"); 23 | await expect(page.getByRole("heading")).toHaveText("`conform` example"); 24 | // Navigate back to the target page. 25 | await page.goBack(); 26 | await expect(firstName).toHaveValue("Tanaka"); 27 | await expect(lastName).toHaveValue("Taro"); 28 | }); 29 | }); 30 | 31 | describe('"dynamic form" is restored on browser back.', () => { 32 | test("browser back on dynamic form", async ({ page }) => { 33 | // Navigate to the target page. 34 | await page.goto("http://localhost:3000/forms/url/dynamic-form"); 35 | // Add 2 member fields. 36 | const addMember = page.getByRole("button", { name: "Add member to last" }); 37 | await addMember.click(); 38 | await addMember.click(); 39 | // Default value is "". 40 | const member1Name = page.getByRole("textbox", { name: "name: " }).first(); 41 | const member2Name = page.getByRole("textbox", { name: "name: " }).nth(1); 42 | await expect(member1Name).toHaveValue(""); 43 | await expect(member2Name).toHaveValue(""); 44 | // Change `firstName` & `lastName`. 45 | await member1Name.fill("Tanaka"); 46 | await setTimeout(1000); // Wait for applying the value to URL. 47 | // Navigate to the top page. 48 | await page.goto("http://localhost:3000"); 49 | await expect(page.getByRole("heading")).toHaveText("`conform` example"); 50 | // Navigate back to the target page. 51 | await page.goBack(); 52 | await expect(member1Name).toHaveValue("Tanaka"); 53 | await expect(member2Name).toHaveValue(""); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/forms/[storeName]/dynamic-form/action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { parseWithZod } from "@conform-to/zod"; 4 | import { redirect } from "next/navigation"; 5 | import { Team } from "./schema"; 6 | 7 | export async function saveTeam(prevState: unknown, formData: FormData) { 8 | const submission = parseWithZod(formData, { 9 | schema: Team, 10 | }); 11 | 12 | if (submission.status !== "success") { 13 | return submission.reply(); 14 | } 15 | 16 | console.log("submit data", submission.value); 17 | 18 | redirect("/success"); 19 | } 20 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/forms/[storeName]/dynamic-form/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getInputProps, useForm } from "@conform-to/react"; 4 | import { parseWithZod } from "@conform-to/zod"; 5 | import { useLocationForm } from "@location-state/conform"; 6 | import { useEffect, useReducer } from "react"; 7 | import { useFormState } from "react-dom"; 8 | import { saveTeam } from "./action"; 9 | import { Team } from "./schema"; 10 | 11 | export default function Form({ storeName }: { storeName: "session" | "url" }) { 12 | const [lastResult, action] = useFormState(saveTeam, undefined); 13 | const [formOptions, getLocationFormProps] = useLocationForm({ 14 | location: { 15 | name: "dynamic-form", 16 | storeName, 17 | }, 18 | }); 19 | const [form, fields] = useForm({ 20 | ...formOptions, 21 | lastResult, 22 | onValidate({ formData }) { 23 | return parseWithZod(formData, { schema: Team }); 24 | }, 25 | }); 26 | const members = fields.members.getFieldList(); 27 | 28 | return ( 29 |
30 |
31 | 40 | 48 |
49 |
    50 | {members.map((member, index) => { 51 | const memberFields = member.getFieldset(); 52 | const numbers = memberFields.numbers.getFieldList(); 53 | 54 | return ( 55 |
  • 56 |
    Member {index + 1}
    57 | 58 | 67 | 77 |
    78 | 87 | {numbers.map((number) => ( 88 |
    89 | 93 |

    {number.errors?.join(", ")}

    94 |
    95 | ))} 96 |
    97 | 106 | 115 | 125 | 135 |
    {memberFields.name.errors?.join(", ")}
    136 |
    {memberFields.engineer.errors?.join(", ")}
    137 |
  • 138 | ); 139 | })} 140 |
141 |
142 | 143 | 146 |
147 |
148 | ); 149 | } 150 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/forms/[storeName]/dynamic-form/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from "./form"; 2 | 3 | export default async function Page({ 4 | params, 5 | }: { 6 | params: Promise<{ storeName: string }>; 7 | }) { 8 | const { storeName } = await params; 9 | 10 | if (storeName !== "session" && storeName !== "url") { 11 | throw new Error("Invalid storeName"); 12 | } 13 | return ( 14 |
15 |

Dynamic Form

16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/forms/[storeName]/dynamic-form/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const Team = z.object({ 4 | leaderId: z.string({ 5 | required_error: "Leader is required", 6 | }), 7 | members: z.array( 8 | z.object({ 9 | id: z.string().min(1), 10 | name: z 11 | .string({ 12 | required_error: "Name is required", 13 | }) 14 | .min(1) 15 | .max(100), 16 | engineer: z.boolean().optional(), 17 | numbers: z.array(z.number()), 18 | }), 19 | ), 20 | }); 21 | 22 | export type Team = z.infer; 23 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/forms/[storeName]/simple-form/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getInputProps, parse, useForm } from "@conform-to/react"; 4 | import { useLocationForm } from "@location-state/conform"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | type FormFields = { 8 | firstName: string; 9 | lastName: string; 10 | }; 11 | 12 | export default function Form({ storeName }: { storeName: "session" | "url" }) { 13 | const router = useRouter(); 14 | const [formOptions, getLocationFormProps] = useLocationForm({ 15 | location: { 16 | name: "simple-form", 17 | storeName, 18 | }, 19 | }); 20 | const [form, fields] = useForm({ 21 | onValidate: ({ formData }) => 22 | parse(formData, { 23 | resolve: (value) => 24 | ({ value }) as { 25 | value: FormFields; 26 | }, 27 | }), 28 | onSubmit(e, { formData }) { 29 | console.log(Object.fromEntries(formData.entries())); 30 | e.preventDefault(); 31 | router.push("/success"); 32 | }, 33 | ...formOptions, 34 | }); 35 | 36 | return ( 37 | 38 |
39 | 40 | 46 |
47 | 56 | 62 |
63 |
{fields.firstName.errors}
64 |
65 |
66 | 67 | 73 |
74 | 83 | 89 |
90 |
{fields.lastName.errors}
91 |
92 |
93 | 94 | 97 |
98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/forms/[storeName]/simple-form/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from "./form"; 2 | 3 | export default async function Page({ 4 | params, 5 | }: { 6 | params: Promise<{ storeName: string }>; 7 | }) { 8 | const { storeName } = await params; 9 | 10 | if (storeName !== "session" && storeName !== "url") { 11 | throw new Error("Invalid storeName"); 12 | } 13 | return ( 14 |
15 |

Simple Form

16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/forms/[storeName]/static-form/action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { parseWithZod } from "@conform-to/zod"; 4 | import { redirect } from "next/navigation"; 5 | import { User } from "./schema"; 6 | 7 | export async function saveUser(prevState: unknown, formData: FormData) { 8 | const submission = parseWithZod(formData, { 9 | schema: User, 10 | }); 11 | 12 | if (submission.status !== "success") { 13 | return submission.reply(); 14 | } 15 | 16 | console.log("submit data", submission.value); 17 | 18 | redirect("/success"); 19 | } 20 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/forms/[storeName]/static-form/form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { getInputProps, useForm } from "@conform-to/react"; 4 | import { parseWithZod } from "@conform-to/zod"; 5 | import { useLocationForm } from "@location-state/conform"; 6 | import { useFormState } from "react-dom"; 7 | import { saveUser } from "./action"; 8 | import { User } from "./schema"; 9 | 10 | export default function Form({ storeName }: { storeName: "session" | "url" }) { 11 | const [lastResult, action] = useFormState(saveUser, undefined); 12 | const [formOptions, getLocationFormProps] = useLocationForm({ 13 | location: { 14 | name: "static-form", 15 | storeName, 16 | }, 17 | }); 18 | const [form, fields] = useForm({ 19 | lastResult, 20 | onValidate({ formData }) { 21 | return parseWithZod(formData, { schema: User }); 22 | }, 23 | ...formOptions, 24 | }); 25 | 26 | return ( 27 | 28 |
29 | 30 | 36 |
37 | 46 | 52 |
53 |
{fields.firstName.errors}
54 |
55 |
56 | 57 | 63 |
64 | 73 | 79 |
80 |
{fields.lastName.errors}
81 |
82 |
83 | 84 | 87 |
88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/forms/[storeName]/static-form/page.tsx: -------------------------------------------------------------------------------- 1 | import Form from "./form"; 2 | 3 | export default async function Page({ 4 | params, 5 | }: { params: Promise<{ storeName: string }> }) { 6 | const { storeName } = await params; 7 | 8 | if (storeName !== "session" && storeName !== "url") { 9 | throw new Error("Invalid storeName"); 10 | } 11 | return ( 12 |
13 |

Static Form

14 |
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/forms/[storeName]/static-form/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const User = z.object({ 4 | firstName: z 5 | .string({ 6 | required_error: "`First name` is required", 7 | }) 8 | .min(1) 9 | .max(100), 10 | lastName: z 11 | .string({ 12 | required_error: "`Last name` is required", 13 | }) 14 | .min(1) 15 | .max(100), 16 | }); 17 | 18 | export type User = z.infer; 19 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Providers } from "./providers"; 2 | 3 | export default function RootLayout({ 4 | children, 5 | }: { 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 | 10 | 11 | {children} 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import Link from "next/link"; 3 | 4 | export const metadata: Metadata = { 5 | title: "`conform` example", 6 | }; 7 | 8 | export default function Home() { 9 | return ( 10 |
11 |

`conform` example

12 |
    13 |
  • 14 | 15 | /forms/session/simple-form 16 | 17 |
  • 18 |
  • 19 | 20 | /forms/session/static-form 21 | 22 |
  • 23 |
  • 24 | 25 | /forms/session/dynamic-form 26 | 27 |
  • 28 |
  • 29 | /forms/url/simple-form 30 |
  • 31 |
  • 32 | /forms/url/static-form 33 |
  • 34 |
  • 35 | /forms/url/dynamic-form 36 |
  • 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LocationStateProvider } from "@location-state/core"; 4 | import type { ReactNode } from "react"; 5 | 6 | export function Providers({ children }: { children: ReactNode }) { 7 | return {children}; 8 | } 9 | -------------------------------------------------------------------------------- /apps/example-next-conform/src/app/success/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 |

Success!

7 |

8 | top page 9 |

10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/example-next-conform/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "baseUrl": "./src", 23 | "paths": { 24 | "@/*": ["*"] 25 | } 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # playwright 38 | playwright-report 39 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/README.md: -------------------------------------------------------------------------------- 1 | # Examples next app 2 | 3 | ## Getting Started 4 | 5 | ``` 6 | $ pnpm i 7 | $ pnpm run dev 8 | ``` 9 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | scrollRestoration: true, 5 | }, 6 | 7 | productionBrowserSourceMaps: true, 8 | webpack: (config, { isServer }) => { 9 | if (isServer) { 10 | config.devtool = "source-map"; 11 | } 12 | return config; 13 | }, 14 | }; 15 | 16 | module.exports = nextConfig; 17 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-next-custom-store", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@location-state/core": "workspace:*", 12 | "@location-state/next": "workspace:*", 13 | "@types/node": "22.15.29", 14 | "@types/react": "19.1.5", 15 | "@types/react-dom": "19.1.5", 16 | "next": "15.3.3", 17 | "qs": "6.14.0", 18 | "react": "19.1.0", 19 | "react-dom": "19.1.0", 20 | "typescript": "5.8.2", 21 | "zod": "3.25.28" 22 | }, 23 | "devDependencies": { 24 | "@types/qs": "6.14.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/src/app/_components/Providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | LocationStateProvider, 5 | URLStore, 6 | createDefaultStores, 7 | } from "@location-state/core"; 8 | import qs from "qs"; 9 | 10 | export function Providers({ children }: { children: React.ReactNode }) { 11 | return ( 12 | ({ 14 | ...createDefaultStores(syncer), 15 | // override the url store 16 | url: new URLStore(syncer, { 17 | encode: encodeUrlState, 18 | decode: decodeUrlState, 19 | }), 20 | })} 21 | > 22 | {children} 23 | 24 | ); 25 | } 26 | 27 | function encodeUrlState(url: string, state?: Record) { 28 | const prevURL = new URL(url); 29 | if (state) { 30 | const href = prevURL.href.replace(/\?.*/, ""); 31 | const newUrl = new URL(`${href}?${qs.stringify(state)}`); 32 | return newUrl.toString(); 33 | } 34 | return prevURL.toString(); 35 | } 36 | 37 | function decodeUrlState(url: string): Record { 38 | const currentURL = new URL(url); 39 | return qs.parse(currentURL.search.replace(/^\?/, ""), { 40 | // see: https://github.com/ljharb/qs/issues/91#issuecomment-348481496 41 | decoder(value) { 42 | if (/^(\d+|\d*\.\d+)$/.test(value)) { 43 | return Number.parseFloat(value); 44 | } 45 | 46 | const keywords = { 47 | true: true, 48 | false: false, 49 | null: null, 50 | undefined: undefined, 51 | }; 52 | if (value in keywords) { 53 | // @ts-ignore 54 | return keywords[value]; 55 | } 56 | 57 | return value; 58 | }, 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/src/app/dynamic/page.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import { headers } from "next/headers"; 4 | import Link from "next/link"; 5 | 6 | export default async function Page() { 7 | const headersList = await headers(); 8 | const referer = headersList.get("referer"); 9 | 10 | return ( 11 |
12 |

Dynamic page

13 | /(top) 14 |

referer: {referer}

15 | 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Providers } from "@/app/_components/Providers"; 2 | import type { Metadata } from "next"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Create Next App", 6 | description: "Generated by create next app", 7 | }; 8 | 9 | export default function RootLayout({ 10 | children, 11 | }: { 12 | children: React.ReactNode; 13 | }) { 14 | return ( 15 | 16 | 17 | {children} 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import Link from "next/link"; 4 | 5 | export default function Page() { 6 | return ( 7 |
8 |

Top page

9 |
    10 |
  • 11 | /static 12 |
  • 13 |
  • 14 | /dynamic 15 |
  • 16 |
17 | 18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/src/app/static/page.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import Link from "next/link"; 4 | 5 | export default function Page() { 6 | return ( 7 |
8 |

Static page

9 | /(top) 10 | 11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/src/components/Counter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { Refine } from "@location-state/core"; 4 | import { useLocationState } from "@location-state/core"; 5 | import { useId } from "react"; 6 | import { type ZodType, z } from "zod"; 7 | 8 | const zodRefine = 9 | (schema: ZodType): Refine => 10 | (value) => { 11 | const result = schema.safeParse(value); 12 | return result.success ? result.data : undefined; 13 | }; 14 | 15 | export function Counter() { 16 | const [counter, setCounter] = useLocationState({ 17 | name: "counter", 18 | defaultValue: 0, 19 | storeName: "url", 20 | refine: zodRefine(z.number()), 21 | }); 22 | console.debug("rendered Counter", { counter }); 23 | 24 | const sectionId = useId(); 25 | 26 | return ( 27 |
28 |

in memory store counter

29 |

30 | counter: {counter} 31 |

32 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/src/components/List.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useLocationState } from "@location-state/core"; 4 | import { useId } from "react"; 5 | 6 | export function List() { 7 | const [displayList, setDisplayList] = useLocationState({ 8 | name: "display-list", 9 | defaultValue: false, 10 | storeName: "url", 11 | }); 12 | const list = Array(100).fill(0); 13 | console.debug("rendered List", { displayList }); 14 | 15 | const sectionId = useId(); 16 | const accordionId = useId(); 17 | 18 | return ( 19 |
20 |

in memory store list

21 | 31 |
    37 | {list.map((_, index) => ( 38 | // biome-ignore lint: noArrayIndexKey 39 |
  • {index}
  • 40 | ))} 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /apps/example-next-custom-store/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | # playwright 38 | playwright-report 39 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/README.md: -------------------------------------------------------------------------------- 1 | # Examples next app 2 | 3 | ## Getting Started 4 | 5 | ``` 6 | $ pnpm i 7 | $ pnpm run dev 8 | ``` 9 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | scrollRestoration: true, 5 | }, 6 | 7 | productionBrowserSourceMaps: true, 8 | webpack: (config, { isServer }) => { 9 | if (isServer) { 10 | config.devtool = "source-map"; 11 | } 12 | return config; 13 | }, 14 | }; 15 | 16 | module.exports = nextConfig; 17 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-next-unsafe-navigation", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "integration-test": "playwright test", 10 | "integration-test:ui": "playwright test --ui" 11 | }, 12 | "dependencies": { 13 | "@location-state/core": "workspace:*", 14 | "@location-state/next": "workspace:*", 15 | "@types/node": "22.15.29", 16 | "@types/react": "19.1.5", 17 | "@types/react-dom": "19.1.5", 18 | "next": "15.3.3", 19 | "react": "19.1.0", 20 | "react-dom": "19.1.0", 21 | "typescript": "5.8.2", 22 | "zod": "3.25.28" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | export default defineConfig({ 4 | // Look for test files in the "tests" directory, relative to this configuration file. 5 | testDir: "playwright", 6 | // Run all tests in parallel. 7 | fullyParallel: true, // Fail the build on CI if you accidentally left test.only in the source code. 8 | forbidOnly: !!process.env.CI, // Retry on CI only. 9 | retries: process.env.CI ? 2 : 0, // Opt out of parallel tests on CI. 10 | workers: process.env.CI ? 1 : undefined, // Reporter to use 11 | reporter: "line", 12 | use: { 13 | // Base URL to use in actions like `await page.goto('/')`. 14 | baseURL: "http://127.0.0.1:3000", // Collect trace when retrying the failed test. 15 | trace: "on-first-retry", 16 | }, 17 | // Configure projects for major browsers. 18 | projects: [ 19 | { 20 | name: "chromium", 21 | use: { ...devices["Desktop Chrome"] }, 22 | }, 23 | { 24 | name: "webkit", 25 | use: { ...devices["Desktop Safari"] }, 26 | }, 27 | ], 28 | // Run your local dev server before starting the tests. 29 | webServer: { 30 | command: "pnpm build && pnpm start", 31 | url: "http://127.0.0.1:3000", 32 | reuseExistingServer: !process.env.CI, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/playwright/session-counter.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { expect, test } from "@playwright/test"; 3 | 4 | // Safari/Firefox: different from actual behavior 5 | test.skip(({ browserName }) => browserName !== "chromium", "Chromium only!"); 6 | 7 | describe('"session counter" is restored on browser back.', () => { 8 | [["http://127.0.0.1:3000/static"], ["http://127.0.0.1:3000/dynamic"]].forEach( 9 | ([url]) => { 10 | test(`browser back on "page: ${url}`, async ({ page }) => { 11 | // Navigate to the target page. 12 | await page.goto(url); 13 | const sessionRegion = page.getByRole("region", { 14 | name: "session store counter", 15 | }); 16 | // Default counter is 0. 17 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 18 | "counter: 0", 19 | ); 20 | // Click the `session increment` button. 21 | await sessionRegion.getByRole("button", { name: "increment" }).click(); 22 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 23 | "counter: 1", 24 | ); 25 | // Navigate to the top page. 26 | await page.getByRole("link", { name: "top" }).click(); 27 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 28 | "counter: 0", 29 | ); 30 | // Navigate back to the target page. 31 | await page.goBack(); 32 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 33 | "counter: 1", 34 | ); 35 | }); 36 | }, 37 | ); 38 | }); 39 | 40 | describe('"session counter" is restored on reload.', () => { 41 | [["http://127.0.0.1:3000/static"], ["http://127.0.0.1:3000/dynamic"]].forEach( 42 | ([url]) => { 43 | test(`reload on "page: ${url}`, async ({ page }) => { 44 | // Navigate to the target page. 45 | await page.goto(url); 46 | const sessionRegion = page.getByRole("region", { 47 | name: "session store counter", 48 | }); 49 | // Default counter is 0. 50 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 51 | "counter: 0", 52 | ); 53 | // Click the `session increment` button. 54 | await sessionRegion.getByRole("button", { name: "increment" }).click(); 55 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 56 | "counter: 1", 57 | ); 58 | // reload the page. 59 | await page.reload(); 60 | await expect(sessionRegion.getByRole("paragraph")).toHaveText( 61 | "counter: 1", 62 | ); 63 | }); 64 | }, 65 | ); 66 | }); 67 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/playwright/session-list.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { expect, test } from "@playwright/test"; 3 | 4 | // Safari/Firefox: different from actual behavior 5 | test.skip(({ browserName }) => browserName !== "chromium", "Chromium only!"); 6 | 7 | describe('"session list" is restored on browser back.', () => { 8 | [["http://127.0.0.1:3000/static"], ["http://127.0.0.1:3000/dynamic"]].forEach( 9 | ([url]) => { 10 | test(`browser back on "page: ${url}`, async ({ page }) => { 11 | // Navigate to the target page. 12 | await page.goto(url); 13 | const sessionRegion = page.getByRole("region", { 14 | name: "session store list", 15 | }); 16 | // Default list is empty. 17 | await expect(sessionRegion.getByRole("list")).toHaveCount(0); 18 | // Click the `session increment` button. 19 | await sessionRegion 20 | .getByRole("checkbox", { name: "Display List" }) 21 | .click(); 22 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 23 | // Navigate to the top page. 24 | await page.getByRole("link", { name: "top" }).click(); 25 | await expect(sessionRegion.getByRole("list")).toHaveCount(0); 26 | // Navigate back to the target page. 27 | await page.goBack(); 28 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 29 | }); 30 | }, 31 | ); 32 | }); 33 | 34 | describe('"session list" is restored on reload.', () => { 35 | [["http://127.0.0.1:3000/static"], ["http://127.0.0.1:3000/dynamic"]].forEach( 36 | ([url]) => { 37 | test(`reload on "page: ${url}`, async ({ page }) => { 38 | // Navigate to the target page. 39 | await page.goto(url); 40 | const sessionRegion = page.getByRole("region", { 41 | name: "session store list", 42 | }); 43 | // Default list is empty. 44 | await expect(sessionRegion.getByRole("list")).toHaveCount(0); 45 | // Click the `session increment` button. 46 | await sessionRegion 47 | .getByRole("checkbox", { name: "Display List" }) 48 | .click(); 49 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 50 | // Navigate back to the target page. 51 | await page.reload(); 52 | await expect(sessionRegion.getByRole("list")).toHaveCount(1); 53 | }); 54 | }, 55 | ); 56 | }); 57 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/playwright/url-counter.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { expect, test } from "@playwright/test"; 3 | 4 | describe('"url counter" is restored on browser back.', () => { 5 | [["http://127.0.0.1:3000/static"], ["http://127.0.0.1:3000/dynamic"]].forEach( 6 | ([url]) => { 7 | test(`browser back on "page: ${url}`, async ({ page }) => { 8 | // Navigate to the target page. 9 | await page.goto(url); 10 | const urlRegion = page.getByRole("region", { 11 | name: "url store counter", 12 | }); 13 | // Default counter is 0. 14 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 0"); 15 | // Click the `url increment` button. 16 | await urlRegion.getByRole("button", { name: "increment" }).click(); 17 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 18 | // Navigate to the top page. 19 | await page.getByRole("link", { name: "top" }).click(); 20 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 0"); 21 | // Navigate back to the target page. 22 | await page.goBack(); 23 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 24 | expect(page.url()).toBe( 25 | `${url}?location-state=%7B%22counter%22%3A1%7D`, 26 | ); 27 | }); 28 | }, 29 | ); 30 | }); 31 | 32 | describe('"url counter" is restored on reload.', () => { 33 | [["http://127.0.0.1:3000/static"], ["http://127.0.0.1:3000/dynamic"]].forEach( 34 | ([url]) => { 35 | test(`reload on "page: ${url}`, async ({ page }) => { 36 | // Navigate to the target page. 37 | await page.goto(url); 38 | const urlRegion = page.getByRole("region", { 39 | name: "url store counter", 40 | }); 41 | // Default counter is 0. 42 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 0"); 43 | // Click the `url increment` button. 44 | await urlRegion.getByRole("button", { name: "increment" }).click(); 45 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 46 | // reload the page. 47 | await page.reload(); 48 | await expect(urlRegion.getByRole("paragraph")).toHaveText("counter: 1"); 49 | expect(page.url()).toBe( 50 | `${url}?location-state=%7B%22counter%22%3A1%7D`, 51 | ); 52 | }); 53 | }, 54 | ); 55 | }); 56 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/playwright/url-list.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "node:test"; 2 | import { expect, test } from "@playwright/test"; 3 | 4 | describe('"url list" is restored on browser back.', () => { 5 | [["http://127.0.0.1:3000/static"], ["http://127.0.0.1:3000/dynamic"]].forEach( 6 | ([url]) => { 7 | test(`browser back on "page: ${url}`, async ({ page }) => { 8 | // Navigate to the target page. 9 | await page.goto(url); 10 | const urlRegion = page.getByRole("region", { 11 | name: "url store list", 12 | }); 13 | // Default list is empty. 14 | await expect(urlRegion.getByRole("list")).toHaveCount(0); 15 | // Click the `url increment` button. 16 | await urlRegion.getByRole("checkbox", { name: "Display List" }).click(); 17 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 18 | // Navigate to the top page. 19 | await page.getByRole("link", { name: "top" }).click(); 20 | await expect(urlRegion.getByRole("list")).toHaveCount(0); 21 | // Navigate back to the target page. 22 | await page.goBack(); 23 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 24 | expect(page.url()).toBe( 25 | `${url}?location-state=%7B%22display-list%22%3Atrue%7D`, 26 | ); 27 | }); 28 | }, 29 | ); 30 | }); 31 | 32 | describe('"url list" is restored on reload.', () => { 33 | [["http://127.0.0.1:3000/static"], ["http://127.0.0.1:3000/dynamic"]].forEach( 34 | ([url]) => { 35 | test(`reload on "page: ${url}`, async ({ page }) => { 36 | // Navigate to the target page. 37 | await page.goto(url); 38 | const urlRegion = page.getByRole("region", { 39 | name: "url store list", 40 | }); 41 | // Default list is empty. 42 | await expect(urlRegion.getByRole("list")).toHaveCount(0); 43 | // Click the `url increment` button. 44 | await urlRegion.getByRole("checkbox", { name: "Display List" }).click(); 45 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 46 | // Navigate back to the target page. 47 | await page.reload(); 48 | await expect(urlRegion.getByRole("list")).toHaveCount(1); 49 | expect(page.url()).toBe( 50 | `${url}?location-state=%7B%22display-list%22%3Atrue%7D`, 51 | ); 52 | }); 53 | }, 54 | ); 55 | }); 56 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/src/app/_components/Providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { LocationStateProvider, NavigationSyncer } from "@location-state/core"; 4 | import { unsafeNavigation } from "@location-state/core/unsafe-navigation"; 5 | 6 | export function Providers({ children }: { children: React.ReactNode }) { 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/src/app/dynamic/page.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import { headers } from "next/headers"; 4 | import Link from "next/link"; 5 | 6 | export default async function Page() { 7 | const headersList = await headers(); 8 | const referer = headersList.get("referer"); 9 | 10 | return ( 11 |
12 |

Dynamic page

13 | /(top) 14 |

referer: {referer}

15 | 16 | 17 | 18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Providers } from "@/app/_components/Providers"; 2 | import type { Metadata } from "next"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Create Next App", 6 | description: "Generated by create next app", 7 | }; 8 | 9 | export default function RootLayout({ 10 | children, 11 | }: { 12 | children: React.ReactNode; 13 | }) { 14 | return ( 15 | 16 | 17 | {children} 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import Link from "next/link"; 4 | 5 | export default function Page() { 6 | return ( 7 |
8 |

Top page

9 |
    10 |
  • 11 | /static 12 |
  • 13 |
  • 14 | /dynamic 15 |
  • 16 |
17 | 18 | 19 | 20 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/src/app/static/page.tsx: -------------------------------------------------------------------------------- 1 | import { Counter } from "@/components/Counter"; 2 | import { List } from "@/components/List"; 3 | import Link from "next/link"; 4 | 5 | export default function Page() { 6 | return ( 7 |
8 |

Static page

9 | /(top) 10 | 11 | 12 | 13 | 14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/src/components/Counter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | type DefaultStoreName, 5 | type Refine, 6 | useLocationState, 7 | } from "@location-state/core"; 8 | import { useId } from "react"; 9 | import { type ZodType, z } from "zod"; 10 | 11 | const zodRefine = 12 | (schema: ZodType): Refine => 13 | (value) => { 14 | const result = schema.safeParse(value); 15 | return result.success ? result.data : undefined; 16 | }; 17 | 18 | export function Counter({ storeName }: { storeName: DefaultStoreName }) { 19 | const [counter, setCounter] = useLocationState({ 20 | name: "counter", 21 | defaultValue: 0, 22 | storeName, 23 | refine: zodRefine(z.number()), 24 | }); 25 | console.debug("rendered Counter", { storeName, counter }); 26 | 27 | const sectionId = useId(); 28 | 29 | return ( 30 |
31 |

{storeName} store counter

32 |

33 | counter: {counter} 34 |

35 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/src/components/List.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { type DefaultStoreName, useLocationState } from "@location-state/core"; 4 | import { useId } from "react"; 5 | 6 | export function List({ storeName }: { storeName: DefaultStoreName }) { 7 | const [displayList, setDisplayList] = useLocationState({ 8 | name: "display-list", 9 | defaultValue: false, 10 | storeName, 11 | }); 12 | const list = Array(100).fill(0); 13 | console.debug("rendered List", { storeName, displayList }); 14 | 15 | const sectionId = useId(); 16 | const accordionId = useId(); 17 | 18 | return ( 19 |
20 |

{storeName} store list

21 | 31 |
    37 | {list.map((_, index) => ( 38 | // biome-ignore lint: noArrayIndexKey 39 |
  • {index}
  • 40 | ))} 41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /apps/example-next-unsafe-navigation/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/@biomejs/biome/configuration_schema.json", 3 | "files": { 4 | "ignore": [ 5 | ".vscode/**", 6 | "**/tsconfig.*.json", 7 | "**/node_modules", 8 | "**/.next", 9 | "**/types/**", 10 | "**/test-results/**", 11 | "**/dist/**", 12 | "backend", 13 | "playwright-report", 14 | "**/styled-system/**", 15 | "**/.turbo/**", 16 | "pnpm-lock.yaml" 17 | ], 18 | "ignoreUnknown": true 19 | }, 20 | "formatter": { 21 | "enabled": true, 22 | "formatWithErrors": false, 23 | "ignore": [ 24 | ".vscode/**", 25 | "**/tsconfig.*.json", 26 | "**/node_modules", 27 | "**/.next", 28 | "**/package.json", 29 | "**/types/**", 30 | "**/test-results/**", 31 | "**/dist/**", 32 | "backend", 33 | "playwright-report", 34 | "**/styled-system/**", 35 | "**/.turbo/**", 36 | "pnpm-lock.yaml" 37 | ], 38 | "indentWidth": 2, 39 | "indentStyle": "space" 40 | }, 41 | "linter": { 42 | "enabled": true, 43 | "rules": { 44 | "recommended": true, 45 | "complexity": { 46 | "noForEach": "off" 47 | }, 48 | "style": { 49 | "noNonNullAssertion": "off" 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "location-state", 3 | "private": true, 4 | "version": "0.0.0", 5 | "description": "State management library for React that synchronizes with history entries supporting Next.js App Router.", 6 | "engines": { 7 | "pnpm": "10.8.1", 8 | "npm": "please_use_pnpm_instead" 9 | }, 10 | "packageManager": "pnpm@10.8.1", 11 | "keywords": [], 12 | "license": "MIT", 13 | "scripts": { 14 | "dev:packages": "turbo dev --filter='./packages/*'", 15 | "build": "turbo build", 16 | "build:packages": "turbo build --filter='./packages/*'", 17 | "test": "turbo test", 18 | "integration-test": "turbo integration-test --concurrency=1", 19 | "typecheck": "turbo typecheck", 20 | "check": "biome check .", 21 | "check:apply": "biome check . --apply --no-errors-on-unmatched", 22 | "ci-check": "pnpm check && turbo dts typecheck build test", 23 | "commit-check": "pnpm check && turbo typecheck test", 24 | "prepare": "husky install" 25 | }, 26 | "lint-staged": { 27 | "*.{js,jsx,ts,tsx,css,json,cjs,mjs}": "biome check --apply" 28 | }, 29 | "devDependencies": { 30 | "@biomejs/biome": "1.9.4", 31 | "@changesets/cli": "2.29.4", 32 | "@playwright/test": "1.52.0", 33 | "@types/node": "22.15.29", 34 | "@vitejs/plugin-react": "4.5.0", 35 | "husky": "9.1.7", 36 | "jsdom": "26.1.0", 37 | "lint-staged": "16.1.0", 38 | "playwright": "1.52.0", 39 | "tsup": "8.5.0", 40 | "turbo": "2.5.4", 41 | "typescript": "5.8.2" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/configs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/configs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT" 6 | } 7 | -------------------------------------------------------------------------------- /packages/configs/tsconfig.dts.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "emitDeclarationOnly": true, 5 | "noEmit": null, // This is required to emit the declaration files 6 | "declaration": true, 7 | "jsx": "react-jsx" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/configs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "react-jsx" 16 | }, 17 | "exclude": ["**/node_modules"] 18 | } 19 | -------------------------------------------------------------------------------- /packages/location-state-conform/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @location-state/conform 2 | 3 | ## 1.2.2 4 | 5 | ### Patch Changes 6 | 7 | - b6cd496: Fix @location-state/conform types: strict check. 8 | 9 | ## 1.2.1 10 | 11 | ### Patch Changes 12 | 13 | - 1b1a8d5: Fixed 404 error for internal package dependencies. 14 | 15 | ## 1.2.0 16 | 17 | ## 1.1.0 18 | 19 | ### Minor Changes 20 | 21 | - 0bad20c: Added `@location-state/conform` that is conform support package. 22 | -------------------------------------------------------------------------------- /packages/location-state-conform/README.md: -------------------------------------------------------------------------------- 1 | # `@location-state/conform` 2 | 3 | [![npm version](https://badge.fury.io/js/@location-state%2Fconform.svg)](https://badge.fury.io/js/@location-state%2Fconform) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | 6 | Synchronize [conform](https://conform.guide/) with history entries. 7 | 8 | ## Features 9 | 10 | - Manage conform state to synchronize with the history location. 11 | - Supports Session Storage and URL as persistent destinations. View the more detail in the [`@location-state/core` docs](/packages/location-state-core/README.md). 12 | 13 | ## Packages 14 | 15 | - [@location-state/core](/packages/location-state-core/README.md): Framework agnostic, but for Next.js App Router. 16 | - [@location-state/conform](/packages/location-state-core/README.md): For conform. 17 | 18 | ## Quickstart for [Next.js App Router with Conform](https://conform.guide/integration/nextjs) 19 | 20 | ### Installation 21 | 22 | ```sh 23 | npm install @location-state/core @location-state/conform 24 | # or 25 | yarn add @location-state/core @location-state/conform 26 | # or 27 | pnpm add @location-state/core @location-state/conform 28 | ``` 29 | 30 | ### Configuration 31 | 32 | ```tsx 33 | // src/app/Providers.tsx 34 | "use client"; 35 | 36 | import { LocationStateProvider } from "@location-state/core"; 37 | 38 | export function Providers({ children }: { children: React.ReactNode }) { 39 | return {children}; 40 | } 41 | ``` 42 | 43 | ```tsx 44 | // src/app/layout.tsx 45 | import { Providers } from "./Providers"; 46 | 47 | // ...snip... 48 | 49 | export default function RootLayout({ 50 | children, 51 | }: { 52 | children: React.ReactNode; 53 | }) { 54 | return ( 55 | 56 | 57 | {children} 58 | 59 | 60 | ); 61 | } 62 | ``` 63 | 64 | ### Working with Conform state 65 | 66 | ```tsx 67 | // user-form.tsx 68 | "use client"; 69 | 70 | import { getInputProps, useForm } from "@conform-to/react"; 71 | import { parseWithZod } from "@conform-to/zod"; 72 | import { useLocationForm } from "@location-state/conform"; 73 | import { useFormState } from "react-dom"; 74 | import { User } from "./schema"; // Your schema 75 | 76 | export default function UserForm() { 77 | const [formOptions, getLocationFormProps] = useLocationForm({ 78 | location: { 79 | name: "your-form", // Unique form name 80 | storeName: "session", // or "url" 81 | }, 82 | }); 83 | const [form, fields] = useForm({ 84 | onValidate({ formData }) { 85 | return parseWithZod(formData, { schema: User }); 86 | }, 87 | ...formOptions, // Pass the form options from `useLocationForm` 88 | }); 89 | 90 | return ( 91 | // Use `getLocationFormProps` to get the form props 92 | 93 | 94 | 100 |
{fields.firstName.errors}
101 | 102 | 103 | ); 104 | } 105 | ``` 106 | 107 | ### Working with Conform and Server Actions 108 | 109 | ```ts 110 | // action.ts 111 | "use server"; 112 | 113 | import { parseWithZod } from "@conform-to/zod"; 114 | import { redirect } from "next/navigation"; 115 | import { User } from "./schema"; 116 | 117 | export async function saveUser(prevState: unknown, formData: FormData) { 118 | const submission = parseWithZod(formData, { 119 | schema: User, 120 | }); 121 | 122 | if (submission.status !== "success") { 123 | return submission.reply(); 124 | } 125 | 126 | console.log("submit data", submission.value); 127 | 128 | redirect("/success"); 129 | } 130 | ``` 131 | 132 | ```tsx 133 | // user-form.tsx 134 | "use client"; 135 | 136 | import { getInputProps, useForm } from "@conform-to/react"; 137 | import { parseWithZod } from "@conform-to/zod"; 138 | import { useLocationForm } from "@location-state/conform"; 139 | import { useFormState } from "react-dom"; 140 | import { saveUser } from "./action"; // Your action 141 | import { User } from "./schema"; // Your schema 142 | 143 | export default function UserForm() { 144 | const [lastResult, action] = useFormState(saveUser, undefined); 145 | const [formOptions, getLocationFormProps] = useLocationForm({ 146 | location: { 147 | name: "your-form", // Unique form name 148 | storeName: "session", // or "url" 149 | }, 150 | }); 151 | const [form, fields] = useForm({ 152 | lastResult, 153 | onValidate({ formData }) { 154 | return parseWithZod(formData, { schema: User }); 155 | }, 156 | ...formOptions, // Pass the form options from `useLocationForm` 157 | }); 158 | 159 | return ( 160 | // Use `getLocationFormProps` to get the form props 161 |
162 | 163 | 169 |
{fields.firstName.errors}
170 | 171 |
172 | ); 173 | } 174 | ``` 175 | 176 | ## API 177 | 178 | View the API reference [here](./docs/API.md). 179 | -------------------------------------------------------------------------------- /packages/location-state-conform/docs/API.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | - [Form hooks](#Form-hooks) 4 | - [function `useLocationForm`](#function-useLocationForm) 5 | - [type `GetLocationFormProps`](#type-GetLocationFormProps) 6 | 7 | ## Form hooks 8 | 9 | Form hooks that are used to manage the state of a form implemented with Conform. 10 | 11 | ### function `useLocationForm` 12 | 13 | ```ts 14 | declare function useLocationForm>({ location, idPrefix, }: { 15 | location: { 16 | name: string; 17 | storeName: "session" | "url"; 18 | refine?: Refine>; 19 | }; 20 | idPrefix?: string; 21 | }): [ 22 | { 23 | id: string; 24 | }, 25 | GetLocationFormProps 26 | ]; 27 | ``` 28 | 29 | Returns a Conform options and function to get form props. 30 | 31 | #### Type Parameters 32 | 33 | - `Schema`: The schema of the form. 34 | 35 | #### Parameters 36 | 37 | - `location`: Partial options of `LocationStateDefinition` from `@location-state/core`. View the more detail in the [API docs](/packages/location-state-core/docs/API.md). 38 | - `name`: The name of the form. 39 | - `storeName`: The store name of the form. It can be `session` or `url`. 40 | - `refine`: The refine function of the form. 41 | - `idPrefix`: The prefix of the [form id](https://conform.guide/api/react/useForm#options). 42 | 43 | #### Returns 44 | 45 | Returns an array that first element is the Conform options for passing to the `useForm` argument and the second element is the function to get form props. 46 | 47 | #### Example 48 | 49 | ```tsx 50 | const [formOptions, getLocationFormProps] = useLocationForm({ 51 | location: { 52 | name: "your-form", 53 | storeName: "session", 54 | }, 55 | }); 56 | 57 | const [form, fields] = useForm({ 58 | lastResult, 59 | onValidate({ formData }) { 60 | return parseWithZod(formData, { schema: User }); 61 | }, 62 | ...formOptions, 63 | }); 64 | 65 | return ( 66 |
67 | ... 68 |
69 | ); 70 | ``` 71 | 72 | ### type `GetLocationFormProps` 73 | 74 | ```ts 75 | // import { getFormProps } from "@conform-to/react"; 76 | type GetFormPropsArgs = Parameters; 77 | type GetLocationFormPropsReturnWith = ReturnType; 78 | type GetLocationFormProps = ( 79 | form: GetFormPropsArgs[0] 80 | ) => GetLocationFormPropsReturnWith & { 81 | onChange: (e: React.ChangeEvent) => void; 82 | }; 83 | ``` 84 | 85 | Returns the form props. This function extends [`getFormProps`](https://conform.guide/api/react/getFormProps) from the `@conform-to/react` package. 86 | 87 | #### Parameters 88 | 89 | - `form`: The form object that is returned from the `useForm` hook. 90 | 91 | #### Returns 92 | 93 | Conform options to pass as argument to `useForm`. 94 | 95 | #### Example 96 | 97 | ```tsx 98 |
99 | ... 100 |
101 | ``` -------------------------------------------------------------------------------- /packages/location-state-conform/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@location-state/conform", 3 | "version": "1.2.2", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "repository": "https://github.com/recruit-tech/location-state", 8 | "description": "Synchronize `conform` with history entries.", 9 | "files": [ 10 | "dist", 11 | "types", 12 | "src", 13 | "package.json" 14 | ], 15 | "exports": { 16 | ".": { 17 | "require": "./dist/index.js", 18 | "import": "./dist/index.mjs", 19 | "types": "./types/index.d.ts" 20 | } 21 | }, 22 | "scripts": { 23 | "dev": "pnpm dev:build & pnpm dev:dts", 24 | "dev:build": "tsup src/index.ts --watch", 25 | "dev:dts": "tsc -p tsconfig.dts.json --watch", 26 | "build": "tsup src/index.ts", 27 | "dts": "tsc -p tsconfig.dts.json", 28 | "typecheck": "tsc", 29 | "test": "vitest run" 30 | }, 31 | "dependencies": { 32 | "valibot": "1.1.0" 33 | }, 34 | "devDependencies": { 35 | "@conform-to/react": "1.6.1", 36 | "@location-state/core": "workspace:*", 37 | "@repo/configs": "workspace:*", 38 | "@repo/test-utils": "workspace:*", 39 | "@repo/utils": "workspace:*", 40 | "@types/react": "19.1.5", 41 | "react": "19.1.0", 42 | "vitest": "3.1.4" 43 | }, 44 | "peerDependencies": { 45 | "@conform-to/react": "^1.0.0", 46 | "@location-state/core": "^1.0.0", 47 | "react": "^18.2.0 || ^19.0.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/location-state-conform/src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { getFormProps } from "@conform-to/react"; 2 | import type { FormMetadata } from "@conform-to/react"; 3 | import { 4 | type LocationStateDefinition, 5 | useLocationGetState, 6 | useLocationKey, 7 | useLocationSetState, 8 | } from "@location-state/core"; 9 | import type { DeepPartial, Pretty } from "@repo/utils/type"; 10 | import { useCallback, useEffect, useId, useRef } from "react"; 11 | import * as v from "valibot"; 12 | import { 13 | InsertIntentValue, 14 | RemoveIntentValue, 15 | ReorderIntentValue, 16 | ResetIntentValue, 17 | SubmitEventValue, 18 | UpdateIntentValue, 19 | } from "./schema"; 20 | import { insertedAt, removedAt, reorderedAt } from "./utils/updated-array"; 21 | import { updatedWithPath } from "./utils/updated-object"; 22 | 23 | type GetLocationFormProps = , FormError>( 24 | metadata: FormMetadata, 25 | options?: Parameters[1], 26 | ) => ReturnType & { 27 | onChange: (e: React.ChangeEvent) => void; 28 | }; 29 | 30 | export function useLocationForm>({ 31 | location, 32 | idPrefix, 33 | }: Pretty< 34 | Pretty<{ 35 | location: Pretty< 36 | Omit>, "defaultValue"> 37 | >; 38 | idPrefix?: string; 39 | }> 40 | >) { 41 | const locationDefinition: LocationStateDefinition< 42 | DeepPartial | undefined 43 | > = { 44 | ...location, 45 | defaultValue: undefined, 46 | }; 47 | const getLocationState = useLocationGetState(locationDefinition); 48 | const setLocationState = useLocationSetState(locationDefinition); 49 | 50 | const randomId = useId(); 51 | const formIdPrefix = idPrefix ?? randomId; 52 | const locationKey = useLocationKey(); 53 | const formIdSuffix = `location-form-${locationKey}`; 54 | const formId = `${formIdPrefix}-${formIdSuffix}`; 55 | 56 | const formRef = useRef(null); 57 | // biome-ignore lint/correctness/useExhaustiveDependencies: 58 | useEffect(() => { 59 | if (!locationKey) return; 60 | if (!formRef.current) { 61 | throw new Error( 62 | "`formRef.current` is null. You need to pass `form` to `getLocationFormProps`.", 63 | ); 64 | } 65 | const values = getLocationState(); 66 | if (values) { 67 | queueMicrotask(() => { 68 | Object.entries(values).forEach(([name, value]) => 69 | formRef.current!.update({ name, value: value! }), 70 | ); 71 | }); 72 | } 73 | }, [locationKey, formId, getLocationState]); 74 | 75 | const getLocationFormProps = useCallback( 76 | (form, option?) => { 77 | formRef.current = form as FormMetadata; 78 | const { onSubmit: onSubmitOriginal, ...formProps } = getFormProps( 79 | form, 80 | option, 81 | ); 82 | 83 | return { 84 | ...formProps, 85 | onChange(e) { 86 | const prevState = getLocationState() ?? ({} as DeepPartial); 87 | const updateValue = 88 | e.target.type === "checkbox" ? e.target.checked : e.target.value; 89 | setLocationState( 90 | updatedWithPath(prevState, e.target.name, updateValue), 91 | ); 92 | }, 93 | onSubmit(e: React.FormEvent) { 94 | const { name, value } = (e.nativeEvent as SubmitEvent) 95 | .submitter as HTMLButtonElement; 96 | // Updating only intent button is submitted 97 | if ( 98 | // https://github.com/edmundhung/conform/blob/ec101a2fb579e5438d443417a582c896bff050df/packages/conform-dom/submission.ts#L62 99 | name === "__intent__" 100 | ) { 101 | const { type, payload } = parseSafe(value); 102 | switch (type) { 103 | case "reset": { 104 | const { name } = v.parse(ResetIntentValue, payload); 105 | const prevState = 106 | getLocationState() ?? ({} as DeepPartial); 107 | const nextState = name 108 | ? updatedWithPath(prevState, name, undefined) 109 | : undefined; 110 | setLocationState(nextState); 111 | break; 112 | } 113 | case "update": { 114 | const { name, value } = v.parse(UpdateIntentValue, payload); 115 | const prevState = 116 | getLocationState() ?? ({} as DeepPartial); 117 | const nextState = updatedWithPath(prevState, name, value); 118 | setLocationState(nextState); 119 | break; 120 | } 121 | case "insert": { 122 | const { name, index, defaultValue } = v.parse( 123 | InsertIntentValue, 124 | payload, 125 | ); 126 | const prevState = 127 | getLocationState() ?? ({} as DeepPartial); 128 | const nextState = updatedWithPath( 129 | prevState, 130 | name, 131 | (prevArray: unknown[] = new Array(index ?? 0).fill({})) => { 132 | const insertionIndex = index ?? prevArray.length; 133 | return insertedAt( 134 | prevArray, 135 | insertionIndex, 136 | defaultValue ?? {}, 137 | ); 138 | }, 139 | ); 140 | setLocationState(nextState); 141 | break; 142 | } 143 | case "remove": { 144 | const { name, index } = v.parse(RemoveIntentValue, payload); 145 | const prevState = 146 | getLocationState() ?? ({} as DeepPartial); 147 | const nextState = updatedWithPath( 148 | prevState, 149 | name, 150 | (prevArray: unknown[]) => removedAt(prevArray, index), 151 | ); 152 | setLocationState(nextState); 153 | break; 154 | } 155 | case "reorder": { 156 | const { name, from, to } = v.parse(ReorderIntentValue, payload); 157 | const prevState = 158 | getLocationState() ?? ({} as DeepPartial); 159 | const nextState = updatedWithPath( 160 | prevState, 161 | name, 162 | (prevArray: unknown[]) => reorderedAt(prevArray, from, to), 163 | ); 164 | setLocationState(nextState); 165 | break; 166 | } 167 | } 168 | } 169 | onSubmitOriginal(e); 170 | }, 171 | }; 172 | }, 173 | [getLocationState, setLocationState], 174 | ) satisfies GetLocationFormProps; 175 | 176 | return [{ id: formId }, getLocationFormProps] as const; 177 | } 178 | 179 | function parseSafe(json: string): { type: string; payload: unknown } { 180 | try { 181 | const jsonParsed = JSON.parse(json); 182 | return v.parse(SubmitEventValue, jsonParsed); 183 | } catch (ignore) { 184 | console.warn("parseSafe failed: ", ignore); 185 | return { type: "", payload: {} }; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /packages/location-state-conform/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hooks"; 2 | -------------------------------------------------------------------------------- /packages/location-state-conform/src/schema.ts: -------------------------------------------------------------------------------- 1 | import * as v from "valibot"; 2 | 3 | export const SubmitEventValue = v.object({ 4 | type: v.string(), 5 | payload: v.unknown(), 6 | }); 7 | 8 | // https://github.com/edmundhung/conform/blob/f955e1c5ba1fd1014c83bc3a1ba4fb215941a108/packages/conform-dom/submission.ts#L310-L321 9 | export const ResetIntentValue = v.object({ 10 | name: v.optional(v.string()), 11 | }); 12 | 13 | // https://github.com/edmundhung/conform/blob/f955e1c5ba1fd1014c83bc3a1ba4fb215941a108/packages/conform-dom/submission.ts#L323-L340 14 | export const UpdateIntentValue = v.object({ 15 | name: v.string(), 16 | value: v.unknown(), 17 | }); 18 | 19 | // https://github.com/edmundhung/conform/blob/1964a3981f0a18703744e3a80ad1487073d97e11/packages/conform-dom/submission.ts#L350-L359 20 | export const InsertIntentValue = v.object({ 21 | name: v.string(), 22 | index: v.optional(v.number()), 23 | defaultValue: v.optional(v.unknown()), 24 | }); 25 | 26 | // https://github.com/edmundhung/conform/blob/1964a3981f0a18703744e3a80ad1487073d97e11/packages/conform-dom/submission.ts#L342-L348 27 | export const RemoveIntentValue = v.object({ 28 | name: v.string(), 29 | index: v.number(), 30 | }); 31 | 32 | // https://github.com/edmundhung/conform/blob/1964a3981f0a18703744e3a80ad1487073d97e11/packages/conform-dom/submission.ts#L361-L368 33 | export const ReorderIntentValue = v.object({ 34 | name: v.string(), 35 | from: v.number(), 36 | to: v.number(), 37 | }); 38 | -------------------------------------------------------------------------------- /packages/location-state-conform/src/utils/updated-array.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { insertedAt, removedAt, reorderedAt } from "./updated-array"; 3 | 4 | describe("`insertAt`", () => { 5 | test.each<{ 6 | explain: string; 7 | src: number[]; 8 | index: number; 9 | value: number; 10 | expected: number[]; 11 | }>([ 12 | { 13 | explain: "should insert an element at the beginning.", 14 | src: [1, 2, 3, 4, 5], 15 | index: 0, 16 | value: 6, 17 | expected: [6, 1, 2, 3, 4, 5], 18 | }, 19 | { 20 | explain: "should insert an element in the middle.", 21 | src: [1, 2, 3, 4, 5], 22 | index: 2, 23 | value: 6, 24 | expected: [1, 2, 6, 3, 4, 5], 25 | }, 26 | { 27 | explain: "should insert an element at the end.", 28 | src: [1, 2, 3, 4, 5], 29 | index: 5, 30 | value: 6, 31 | expected: [1, 2, 3, 4, 5, 6], 32 | }, 33 | { 34 | explain: "should insert an element at the beginning of an empty array.", 35 | src: [], 36 | index: 0, 37 | value: 1, 38 | expected: [1], 39 | }, 40 | { 41 | explain: "should insert an element at the end of an one-element array.", 42 | src: [1], 43 | index: 0, 44 | value: 2, 45 | expected: [2, 1], 46 | }, 47 | { 48 | explain: "should insert an element at the end of an two-element array.", 49 | src: [1], 50 | index: 1, 51 | value: 2, 52 | expected: [1, 2], 53 | }, 54 | ])("$explain", ({ src, index, value, expected }) => { 55 | // Act 56 | const actual = insertedAt(src, index, value); 57 | // Assert 58 | expect(actual).toEqual(expected); 59 | }); 60 | }); 61 | 62 | describe("`removedAt`", () => { 63 | test.each<{ 64 | explain: string; 65 | src: number[]; 66 | index: number; 67 | expected: number[]; 68 | }>([ 69 | { 70 | explain: "should remove an element at the middle.", 71 | src: [1, 2, 3, 4, 5], 72 | index: 2, 73 | expected: [1, 2, 4, 5], 74 | }, 75 | { 76 | explain: "should remove an element at the beginning.", 77 | src: [1, 2, 3, 4, 5], 78 | index: 0, 79 | expected: [2, 3, 4, 5], 80 | }, 81 | { 82 | explain: "should remove an element at the end.", 83 | src: [1, 2, 3, 4, 5], 84 | index: 4, 85 | expected: [1, 2, 3, 4], 86 | }, 87 | { 88 | explain: "should remove an element from an one-element array.", 89 | src: [1], 90 | index: 0, 91 | expected: [], 92 | }, 93 | { 94 | explain: "should remove an element from a two-element array.", 95 | src: [1, 2], 96 | index: 1, 97 | expected: [1], 98 | }, 99 | { 100 | explain: "should remove an element from a two-element array.", 101 | src: [1, 2], 102 | index: 2, 103 | expected: [1, 2], 104 | }, 105 | ])("$explain", ({ src, index, expected }) => { 106 | // Act 107 | const actual = removedAt(src, index); 108 | // Assert 109 | expect(actual).toEqual(expected); 110 | }); 111 | }); 112 | 113 | describe("`reorderedAt`", () => { 114 | test.each<{ 115 | explain: string; 116 | src: number[]; 117 | from: number; 118 | to: number; 119 | expected: number[]; 120 | }>([ 121 | { 122 | explain: "should reorder an element from the beginning to the middle.", 123 | src: [1, 2, 3, 4, 5], 124 | from: 0, 125 | to: 2, 126 | expected: [2, 3, 1, 4, 5], 127 | }, 128 | { 129 | explain: "should reorder an element from the middle to the beginning.", 130 | src: [1, 2, 3, 4, 5], 131 | from: 2, 132 | to: 0, 133 | expected: [3, 1, 2, 4, 5], 134 | }, 135 | { 136 | explain: "should reorder an element from the end to the middle.", 137 | src: [1, 2, 3, 4, 5], 138 | from: 4, 139 | to: 0, 140 | expected: [5, 1, 2, 3, 4], 141 | }, 142 | { 143 | explain: "should reorder an element from the beginning to the end.", 144 | src: [1, 2, 3, 4, 5], 145 | from: 0, 146 | to: 4, 147 | expected: [2, 3, 4, 5, 1], 148 | }, 149 | { 150 | explain: "should reorder an element from the end to the end.", 151 | src: [1, 2, 3, 4, 5], 152 | from: 4, 153 | to: 4, 154 | expected: [1, 2, 3, 4, 5], 155 | }, 156 | { 157 | explain: "should reorder an element from the beginning to the beginning.", 158 | src: [1, 2, 3, 4, 5], 159 | from: 0, 160 | to: 0, 161 | expected: [1, 2, 3, 4, 5], 162 | }, 163 | { 164 | explain: 165 | "should reorder an element from the beginning to the beginning of an one-element array.", 166 | src: [1], 167 | from: 0, 168 | to: 0, 169 | expected: [1], 170 | }, 171 | { 172 | explain: 173 | "should reorder an element from the beginning to the end of an one-element array.", 174 | src: [1, 2], 175 | from: 0, 176 | to: 1, 177 | expected: [2, 1], 178 | }, 179 | { 180 | explain: 181 | "should reorder an element from the end to the beginning of an one-element array.", 182 | src: [1, 2], 183 | from: 1, 184 | to: 0, 185 | expected: [2, 1], 186 | }, 187 | ])("$explain", ({ src, from, to, expected }) => { 188 | // Act 189 | const actual = reorderedAt(src, from, to); 190 | // Assert 191 | expect(actual).toEqual(expected); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /packages/location-state-conform/src/utils/updated-array.ts: -------------------------------------------------------------------------------- 1 | export function insertedAt(array: T[], index: number, value: T): T[] { 2 | return [...array.slice(0, index), value, ...array.slice(index)]; 3 | } 4 | 5 | export function removedAt(array: T[], index: number): T[] { 6 | return [...array.slice(0, index), ...array.slice(index + 1)]; 7 | } 8 | 9 | export function reorderedAt(array: T[], from: number, to: number): T[] { 10 | const nextArray = [...array]; 11 | const removed = nextArray.splice(from, 1); 12 | nextArray.splice(to, 0, ...removed); 13 | return nextArray; 14 | } 15 | -------------------------------------------------------------------------------- /packages/location-state-conform/src/utils/updated-object.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | import { updatedWithPath } from "./updated-object"; 3 | 4 | describe("object path setter", () => { 5 | test.each<{ 6 | explain: string; 7 | src: Record; 8 | path: string; 9 | value: unknown; 10 | expected: Record; 11 | }>([ 12 | { 13 | explain: "update a string value", 14 | src: { a: "a default value" }, 15 | path: "a", 16 | value: "updated value", 17 | expected: { a: "updated value" }, 18 | }, 19 | { 20 | explain: "update a string value to empty object", 21 | src: {}, 22 | path: "a", 23 | value: "updated value", 24 | expected: { a: "updated value" }, 25 | }, 26 | { 27 | explain: "update a number value", 28 | src: { a: 1 }, 29 | path: "a", 30 | value: 2, 31 | expected: { a: 2 }, 32 | }, 33 | { 34 | explain: "update a nested value and keep other values", 35 | src: { 36 | a: { b: "b default value", c: "c default value" }, 37 | d: { e: "d default value" }, 38 | }, 39 | path: "a.b", 40 | value: "updated value", 41 | expected: { 42 | a: { b: "updated value", c: "c default value" }, 43 | d: { e: "d default value" }, 44 | }, 45 | }, 46 | { 47 | explain: "update a nested value in an array and keep other values", 48 | src: { 49 | a: { b: [{ c: "c default value" }] }, 50 | d: { e: "d default value" }, 51 | }, 52 | path: "a.b[0].c", 53 | value: "updated value", 54 | expected: { 55 | a: { b: [{ c: "updated value" }] }, 56 | d: { e: "d default value" }, 57 | }, 58 | }, 59 | { 60 | explain: "update a nested value in an empty array and keep other values", 61 | src: { 62 | a: { b: [] }, 63 | d: { e: "d default value" }, 64 | }, 65 | path: "a.b[0].c", 66 | value: "updated value", 67 | expected: { 68 | a: { b: [{ c: "updated value" }] }, 69 | d: { e: "d default value" }, 70 | }, 71 | }, 72 | { 73 | explain: "update a nested value in a nested array and keep other values", 74 | src: { 75 | a: { b: [{ c: [{ d: "d default value" }] }] }, 76 | d: { e: "d default value" }, 77 | }, 78 | path: "a.b[0].c[0].d", 79 | value: "updated value", 80 | expected: { 81 | a: { b: [{ c: [{ d: "updated value" }] }] }, 82 | d: { e: "d default value" }, 83 | }, 84 | }, 85 | { 86 | explain: "update a nested value in an empty object and keep other values", 87 | src: { 88 | a: {}, 89 | d: { e: "d default value" }, 90 | }, 91 | path: "a.b.c", 92 | value: "updated value", 93 | expected: { 94 | a: { b: { c: "updated value" } }, 95 | d: { e: "d default value" }, 96 | }, 97 | }, 98 | ])("update with $explain .", ({ src, path, value, expected }) => { 99 | // Act 100 | const result = updatedWithPath(src, path, value); 101 | // Assert 102 | expect(result).toEqual(expected); 103 | }); 104 | 105 | test("src is not changed, and return value's some member are same reference.", () => { 106 | // Arrange 107 | const target = { 108 | a: { b: "b default value" }, 109 | c: { d: "c.d default value" }, 110 | }; 111 | // Act 112 | const result = updatedWithPath(target, "a.b", "updated value"); 113 | // Assert 114 | expect(target).toEqual({ 115 | a: { b: "b default value" }, 116 | c: { d: "c.d default value" }, 117 | }); 118 | expect(result).toEqual({ 119 | a: { b: "updated value" }, 120 | c: { d: "c.d default value" }, 121 | }); 122 | expect(result.c).toBe(target.c); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /packages/location-state-conform/src/utils/updated-object.ts: -------------------------------------------------------------------------------- 1 | import { assertArray, assertRecord } from "@repo/utils/asserts"; 2 | 3 | type PrimitiveValue = string | number | boolean; 4 | type Node = 5 | | { 6 | [key: string]: Node | PrimitiveValue | undefined; 7 | } 8 | | Node[]; 9 | 10 | /** 11 | * Returns an updated object with `object` paths. 12 | * 13 | * @param src The object you want to update. 14 | * @param path The path to the value you want to update. You can contain arrays. 15 | * @param updaterOrValue The updater or value. 16 | */ 17 | export function updatedWithPath>( 18 | src: T, 19 | path: string, 20 | updaterOrValue: unknown | ((currentValue: unknown) => unknown), 21 | ): T { 22 | const dest = { ...src }; 23 | const pathSegments = getPathSegments(path); 24 | pathSegments.reduce<[Node, Node]>( 25 | ([currentSrc, currentDest], pathSegment, index) => { 26 | /** 27 | * When last segment is reached, update the value. 28 | */ 29 | if (index === pathSegments.length - 1) { 30 | if (typeof pathSegment === "number") { 31 | assertArray(currentSrc); 32 | assertArray(currentDest); 33 | currentDest[pathSegment] = 34 | typeof updaterOrValue === "function" 35 | ? updaterOrValue(currentSrc[pathSegment]) 36 | : updaterOrValue; 37 | } else { 38 | assertRecord(currentSrc); 39 | assertRecord(currentDest); 40 | currentDest[pathSegment] = 41 | typeof updaterOrValue === "function" 42 | ? updaterOrValue(currentSrc[pathSegment]) 43 | : updaterOrValue; 44 | } 45 | 46 | // Not used, but return the last node for type checking. 47 | return [currentSrc, currentDest]; 48 | } 49 | 50 | /** 51 | * current: Record, next: Array 52 | * Not supported: current: Array, next: Array 53 | */ 54 | const nextPath = pathSegments[index + 1]; 55 | if (typeof nextPath === "number") { 56 | if (typeof pathSegment === "number") { 57 | // e.g. a[0][0] 58 | throw new Error("Not Supported: Nested array in array"); 59 | } 60 | 61 | assertRecord(currentSrc); 62 | assertRecord(currentDest); 63 | const nextSrc = currentSrc[pathSegment]; 64 | assertArray(nextSrc); 65 | currentDest[pathSegment] = [...nextSrc]; 66 | const nextDest = currentDest[pathSegment]; 67 | assertArray(nextDest); 68 | 69 | return [nextSrc, nextDest]; 70 | } 71 | 72 | /** 73 | * current: Array, next: Record 74 | */ 75 | if (typeof pathSegment === "number") { 76 | assertArray(currentSrc); 77 | assertArray(currentDest); 78 | const nextSrc = currentSrc[pathSegment] ?? {}; 79 | assertRecord(nextSrc); 80 | currentDest[pathSegment] = { ...nextSrc }; 81 | const nextDest = currentDest[pathSegment]; 82 | assertRecord(nextDest); 83 | 84 | return [nextSrc, nextDest]; 85 | } 86 | 87 | /** 88 | * current: Record, next: Record 89 | */ 90 | assertRecord(currentSrc); 91 | assertRecord(currentDest); 92 | const nextSrc = currentSrc[pathSegment] ?? {}; 93 | assertRecord(nextSrc); 94 | currentDest[pathSegment] = { ...nextSrc }; 95 | const nextDest = currentDest[pathSegment]; 96 | assertRecord(nextDest); 97 | 98 | return [nextSrc, nextDest]; 99 | }, 100 | [src as Node, dest as Node], 101 | ); 102 | return dest; 103 | } 104 | 105 | // Return path segments separated by `. ` or `[]`. 106 | // https://github.com/edmundhung/conform/blob/28f453bb636b7881ba971c62cf961a84b7b65d51/packages/conform-dom/formdata.ts#L33-L52 107 | export function getPathSegments(path: string): Array { 108 | if (!path) { 109 | return []; 110 | } 111 | 112 | return path 113 | .split(/\.|(\[\d*\])/) 114 | .reduce>((result, segment) => { 115 | if (typeof segment !== "undefined" && segment !== "") { 116 | if (segment.startsWith("[") && segment.endsWith("]")) { 117 | const index = segment.slice(1, -1); 118 | result.push(Number(index)); 119 | } else { 120 | result.push(segment); 121 | } 122 | } 123 | return result; 124 | }, []); 125 | } 126 | -------------------------------------------------------------------------------- /packages/location-state-conform/tsconfig.dts.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/configs/tsconfig.dts.json", 3 | "include": ["src/**/*.ts", "src/**/*.tsx"], 4 | "exclude": ["node_modules", "src/**/*.test.ts", "src/**/*.test.tsx"], 5 | "compilerOptions": { 6 | "declarationDir": "types" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/location-state-conform/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/configs/tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vitest/globals"] 5 | }, 6 | "include": ["src/**/*.ts", "src/**/*.tsx"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/location-state-conform/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | target: "es2020", 5 | format: ["cjs", "esm"], 6 | clean: true, 7 | // CI上ではdts生成より先にbuildが進んでしまうため、以下のissue解消後有効化 8 | // https://github.com/egoist/tsup/issues/921 9 | dts: false, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/location-state-conform/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | include: ["src/**/*.test.ts"], 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/location-state-core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @location-state/core 2 | 3 | ## 1.2.2 4 | 5 | ### Patch Changes 6 | 7 | - b6cd496: Fix @location-state/conform types: strict check. 8 | 9 | ## 1.2.1 10 | 11 | ## 1.2.0 12 | 13 | ### Minor Changes 14 | 15 | - 518d0ae: Throttle URL updates in `URLStore`. 16 | 17 | ## 1.1.0 18 | 19 | ### Minor Changes 20 | 21 | - b69c437: Add support `InMemoryStore`. 22 | - 0bad20c: Added `useLocationGetState`/`useLocationKey` to `@location-state/core`. 23 | 24 | ## 1.0.1 25 | 26 | ### Patch Changes 27 | 28 | - 7e061fc: Initial release. 29 | -------------------------------------------------------------------------------- /packages/location-state-core/README.md: -------------------------------------------------------------------------------- 1 | # `@location-state/core` 2 | 3 | [![npm version](https://badge.fury.io/js/@location-state%2Fcore.svg)](https://badge.fury.io/js/@location-state%2Fcore) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | 6 | State management library for React that synchronizes with history location supporting Next.js App Router. 7 | 8 | ## Features 9 | 10 | - Manage the state to synchronize with the history location. 11 | - By default, supports Session Storage and URL as persistent destinations. 12 | 13 | ## Packages 14 | 15 | - [@location-state/core](/packages/location-state-core/README.md): Framework agnostic, but for Next.js App Router. 16 | - [@location-state/next](/packages/location-state-next/README.md): For Next.js Pages Router. 17 | 18 | ## Quickstart for Next.js [App Router](https://nextjs.org/docs/app) 19 | 20 | ### Installation 21 | 22 | ```sh 23 | npm install @location-state/core 24 | # or 25 | yarn add @location-state/core 26 | # or 27 | pnpm add @location-state/core 28 | ``` 29 | 30 | ### Configuration 31 | 32 | ```tsx 33 | // src/app/Providers.tsx 34 | "use client"; 35 | 36 | import { LocationStateProvider } from "@location-state/core"; 37 | 38 | export function Providers({ children }: { children: React.ReactNode }) { 39 | return {children}; 40 | } 41 | ``` 42 | 43 | ```tsx 44 | // src/app/layout.tsx 45 | import { Providers } from "./Providers"; 46 | 47 | // ...snip... 48 | 49 | export default function RootLayout({ 50 | children, 51 | }: { 52 | children: React.ReactNode; 53 | }) { 54 | return ( 55 | 56 | 57 | {children} 58 | 59 | 60 | ); 61 | } 62 | ``` 63 | 64 | ### Working with state 65 | 66 | ```tsx 67 | "use client"; 68 | 69 | import { useLocationState } from "@location-state/core"; 70 | 71 | export function Counter() { 72 | const [counter, setCounter] = useLocationState({ 73 | name: "counter", 74 | defaultValue: 0, 75 | storeName: "session", 76 | }); 77 | 78 | return ( 79 |
80 |

81 | storeName: {storeName}, counter: {counter} 82 |

83 | 84 |
85 | ); 86 | } 87 | ``` 88 | 89 | ## API 90 | 91 | View the API reference [here](./docs/API.md). 92 | -------------------------------------------------------------------------------- /packages/location-state-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@location-state/core", 3 | "version": "1.2.2", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "repository": "https://github.com/recruit-tech/location-state", 8 | "description": "State management library for React that synchronizes with history entries supporting Next.js App Router.", 9 | "files": [ 10 | "dist", 11 | "types", 12 | "src", 13 | "package.json" 14 | ], 15 | "exports": { 16 | ".": { 17 | "require": "./dist/index.js", 18 | "import": "./dist/index.mjs", 19 | "types": "./types/index.d.ts" 20 | }, 21 | "./unsafe-navigation": { 22 | "require": "./dist/unsafe-navigation.js", 23 | "import": "./dist/unsafe-navigation.mjs", 24 | "types": "./types/unsafe-navigation.d.ts" 25 | } 26 | }, 27 | "scripts": { 28 | "dev": "pnpm dev:build & pnpm dev:dts", 29 | "dev:build": "tsup src/index.ts --watch", 30 | "dev:dts": "tsc -p tsconfig.dts.json --watch", 31 | "build": "tsup src/index.ts src/unsafe-navigation.ts", 32 | "dts": "tsc -p tsconfig.dts.json", 33 | "typecheck": "tsc", 34 | "test": "vitest run" 35 | }, 36 | "devDependencies": { 37 | "@repo/configs": "workspace:*", 38 | "@repo/test-utils": "workspace:*", 39 | "@testing-library/jest-dom": "6.6.3", 40 | "@testing-library/react": "16.3.0", 41 | "@testing-library/user-event": "14.6.1", 42 | "@types/react": "19.1.5", 43 | "@types/uuid": "10.0.0", 44 | "client-only": "0.0.1", 45 | "navigation-api-types": "0.6.1", 46 | "react": "19.1.0", 47 | "uuid": "11.1.0", 48 | "vitest": "3.1.4" 49 | }, 50 | "peerDependencies": { 51 | "react": "^18.2.0 || ^19.0.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/location-state-core/src/context.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import type { Store } from "./stores"; 3 | import type { Syncer } from "./types"; 4 | 5 | export const LocationStateContext = createContext<{ 6 | syncer?: Syncer; 7 | stores: Record; 8 | }>({ 9 | stores: {}, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/location-state-core/src/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useState, useSyncExternalStore } from "react"; 2 | import { LocationStateContext } from "./context"; 3 | import type { DefaultStoreName } from "./types"; 4 | 5 | export type Refine = (value: unknown) => T | undefined; 6 | 7 | export type LocationStateDefinition< 8 | T, 9 | StoreName extends string = DefaultStoreName, 10 | > = { 11 | name: string; 12 | defaultValue: T; 13 | storeName: StoreName; 14 | refine?: Refine; 15 | }; 16 | 17 | type Updater = (prev: T) => T; 18 | type ValueOrUpdater = T | Updater; 19 | type SetState = (valueOrUpdater: ValueOrUpdater) => void; 20 | type GetState = () => T; 21 | 22 | const useStore = (storeName: string) => { 23 | const { stores } = useContext(LocationStateContext); 24 | const store = stores[storeName]; 25 | if (!store) { 26 | throw new Error(`Store not found: ${storeName}`); 27 | } 28 | 29 | return store; 30 | }; 31 | 32 | const _useLocationState = ( 33 | definition: LocationStateDefinition, 34 | ): [T, SetState] => { 35 | const storeState = _useLocationStateValue(definition); 36 | const setStoreState = _useLocationSetState(definition); 37 | return [storeState, setStoreState]; 38 | }; 39 | 40 | const _useLocationStateValue = ( 41 | definition: LocationStateDefinition, 42 | ): T => { 43 | const { name, defaultValue, storeName, refine } = useState(definition)[0]; 44 | const store = useStore(storeName); 45 | const subscribe = useCallback( 46 | (onStoreChange: () => void) => store.subscribe(name, onStoreChange), 47 | [name, store], 48 | ); 49 | const getSnapshot = () => { 50 | const storeValue = store.get(name) as T | undefined; 51 | const refinedValue = refine ? refine(storeValue) : storeValue; 52 | return refinedValue ?? defaultValue; 53 | }; 54 | // `defaultValue` is assumed to always be the same value (for Objects, it must be memoized). 55 | const storeState = useSyncExternalStore( 56 | subscribe, 57 | getSnapshot, 58 | () => defaultValue, 59 | ); 60 | return storeState; 61 | }; 62 | 63 | const _useLocationGetState = ( 64 | definition: LocationStateDefinition, 65 | ): GetState => { 66 | const { name, defaultValue, storeName, refine } = useState(definition)[0]; 67 | const store = useStore(storeName); 68 | return useCallback(() => { 69 | const storeValue = store.get(name) as T | undefined; 70 | const refinedValue = refine ? refine(storeValue) : storeValue; 71 | return refinedValue ?? defaultValue; 72 | }, [store, name, refine, defaultValue]); 73 | }; 74 | 75 | const _useLocationSetState = ( 76 | definition: LocationStateDefinition, 77 | ): SetState => { 78 | const { name, defaultValue, storeName, refine } = useState(definition)[0]; 79 | const store = useStore(storeName); 80 | const setStoreState = useCallback( 81 | (updaterOrValue: ValueOrUpdater) => { 82 | if (typeof updaterOrValue !== "function") { 83 | store.set(name, updaterOrValue); 84 | return; 85 | } 86 | const updater = updaterOrValue as Updater; 87 | const storeValue = store.get(name) as T | undefined; 88 | const refinedValue = refine ? refine(storeValue) : storeValue; 89 | const prev = refinedValue ?? defaultValue; 90 | store.set(name, updater(prev)); 91 | }, 92 | // These values are immutable. 93 | [name, store, defaultValue, refine], 94 | ); 95 | return setStoreState; 96 | }; 97 | 98 | export const getHooksWith = () => 99 | ({ 100 | useLocationState: _useLocationState, 101 | useLocationStateValue: _useLocationStateValue, 102 | useLocationGetState: _useLocationGetState, 103 | useLocationSetState: _useLocationSetState, 104 | }) as { 105 | useLocationState: ( 106 | definition: LocationStateDefinition, 107 | ) => [T, SetState]; 108 | useLocationStateValue: ( 109 | definition: LocationStateDefinition, 110 | ) => T; 111 | useLocationGetState: ( 112 | definition: LocationStateDefinition, 113 | ) => GetState; 114 | useLocationSetState: ( 115 | definition: LocationStateDefinition, 116 | ) => SetState; 117 | }; 118 | 119 | export const { 120 | useLocationState, 121 | useLocationStateValue, 122 | useLocationGetState, 123 | useLocationSetState, 124 | } = getHooksWith(); 125 | 126 | export const useLocationKey = ({ 127 | serverDefault, 128 | clientDefault, 129 | }: 130 | | { 131 | serverDefault?: string; 132 | clientDefault?: string; 133 | } 134 | | undefined = {}) => { 135 | const { syncer } = useContext(LocationStateContext); 136 | if (!syncer) { 137 | throw new Error("syncer not found"); 138 | } 139 | const subscribe = useCallback( 140 | (listener: () => void) => { 141 | const abortController = new AbortController(); 142 | const { signal } = abortController; 143 | syncer.sync({ 144 | listener, 145 | signal, 146 | }); 147 | return () => abortController.abort(); 148 | }, 149 | [syncer], 150 | ); 151 | // https://tkdodo.eu/blog/avoiding-hydration-mismatches-with-use-sync-external-store 152 | return useSyncExternalStore( 153 | subscribe, 154 | () => syncer.key() ?? clientDefault, 155 | () => serverDefault, 156 | ); 157 | }; 158 | -------------------------------------------------------------------------------- /packages/location-state-core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hooks"; 2 | export * from "./provider"; 3 | export * from "./types"; 4 | export * from "./syncers"; 5 | export * from "./stores"; 6 | -------------------------------------------------------------------------------- /packages/location-state-core/src/location-sync.test.tsx: -------------------------------------------------------------------------------- 1 | import { createNavigationMock } from "@repo/test-utils"; 2 | import { renderWithUser } from "@repo/test-utils"; 3 | import { screen, waitFor } from "@testing-library/react"; 4 | import { useEffect, useRef, useState } from "react"; 5 | import { beforeEach, describe, expect, test } from "vitest"; 6 | import { 7 | type LocationStateDefinition, 8 | useLocationGetState, 9 | useLocationSetState, 10 | useLocationState, 11 | useLocationStateValue, 12 | } from "./hooks"; 13 | import { LocationStateProvider } from "./provider"; 14 | import { locationKeyPrefix } from "./stores"; 15 | 16 | const mockNavigation = createNavigationMock("/"); 17 | // @ts-ignore 18 | globalThis.navigation = mockNavigation; 19 | 20 | beforeEach(() => { 21 | mockNavigation.navigate("/"); 22 | sessionStorage.clear(); 23 | }); 24 | 25 | describe("using `useLocationState`.", () => { 26 | function LocationSyncCounter() { 27 | const [count, setCount] = useLocationState({ 28 | name: "count", 29 | defaultValue: 0, 30 | storeName: "session", 31 | }); 32 | return ( 33 |
34 |

count: {count}

35 | 38 | 41 |
42 | ); 43 | } 44 | 45 | function LocationSyncCounterPage() { 46 | return ( 47 | 48 | 49 | 50 | ); 51 | } 52 | 53 | test("`count` can be updated.", async () => { 54 | // Arrange 55 | const { user } = renderWithUser(); 56 | // Act 57 | await user.click(await screen.findByRole("button", { name: "increment" })); 58 | // Assert 59 | expect(screen.getByRole("heading")).toHaveTextContent("count: 1"); 60 | }); 61 | 62 | test("`count` can be updated with updater.", async () => { 63 | // Arrange 64 | const { user } = renderWithUser(); 65 | // Act 66 | await user.click( 67 | await screen.findByRole("button", { name: "increment with updater" }), 68 | ); 69 | // Assert 70 | expect(screen.getByRole("heading")).toHaveTextContent("count: 1"); 71 | }); 72 | 73 | test("`count` is reset at navigation.", async () => { 74 | // Arrange 75 | const { user } = renderWithUser(); 76 | await user.click(await screen.findByRole("button", { name: "increment" })); 77 | // Act 78 | mockNavigation.navigate("/anywhere"); 79 | // Assert 80 | await waitFor(() => 81 | expect(screen.getByRole("heading")).toHaveTextContent("count: 0"), 82 | ); 83 | }); 84 | 85 | test("If there is a value in sessionStorage, it will be restored as initial value.", async () => { 86 | // Arrange 87 | const key = mockNavigation.currentEntry?.key as string; 88 | sessionStorage.setItem(`${locationKeyPrefix}${key}`, `{"count":2}`); 89 | // Act 90 | renderWithUser(); 91 | // Assert 92 | await waitFor(() => 93 | expect(screen.getByRole("heading")).toHaveTextContent("count: 2"), 94 | ); 95 | }); 96 | }); 97 | 98 | describe("using `useLocationStateValue`.", () => { 99 | function LocationSyncCounter() { 100 | const counter: LocationStateDefinition = { 101 | name: "count", 102 | defaultValue: 0, 103 | storeName: "session", 104 | }; 105 | const count = useLocationStateValue(counter); 106 | return

count: {count}

; 107 | } 108 | 109 | function LocationSyncCounterPage() { 110 | return ( 111 | 112 | 113 | 114 | ); 115 | } 116 | 117 | test("If there is a value in sessionStorage, it will be restored as initial value.", async () => { 118 | // Arrange 119 | const key = mockNavigation.currentEntry?.key as string; 120 | sessionStorage.setItem(`${locationKeyPrefix}${key}`, `{"count":2}`); 121 | // Act 122 | renderWithUser(); 123 | // Assert 124 | await waitFor(() => 125 | expect(screen.getByRole("heading")).toHaveTextContent("count: 2"), 126 | ); 127 | }); 128 | 129 | test.todo(`When store's value is changed, component is re-rendered.`); 130 | }); 131 | 132 | describe("using `useLocationSetState`.", () => { 133 | function LocationSyncCounter() { 134 | const counter: LocationStateDefinition = { 135 | name: "counter", 136 | defaultValue: 0, 137 | storeName: "session", 138 | }; 139 | const rendered = useRef(1); 140 | const setCount = useLocationSetState(counter); 141 | useEffect(() => { 142 | rendered.current++; 143 | }, []); 144 | 145 | return ( 146 |
147 |

rendered: {rendered.current}

148 | 151 |
152 | ); 153 | } 154 | 155 | function LocationSyncCounterPage() { 156 | return ( 157 | 158 | 159 | 160 | ); 161 | } 162 | 163 | test("setCount does not re-render.", async () => { 164 | // Arrange 165 | const { user } = renderWithUser(); 166 | // Act 167 | await user.click(await screen.findByRole("button", { name: "increment" })); 168 | // Assert 169 | expect(screen.getByRole("heading")).toHaveTextContent("rendered: 1"); 170 | // todo: assert store's value updated. 171 | }); 172 | }); 173 | 174 | describe("using `useLocationGetState` with `storeName`.", () => { 175 | function LocationSyncOnceCounter() { 176 | const counter: LocationStateDefinition = { 177 | name: "count", 178 | defaultValue: 0, 179 | storeName: "session", 180 | }; 181 | const getState = useLocationGetState(counter); 182 | const [, setState] = useState(false); 183 | 184 | return ( 185 | <> 186 |

count: {getState()}

187 | 190 | 191 | ); 192 | } 193 | 194 | function LocationSyncCounterPage() { 195 | return ( 196 | 197 | 198 | 199 | ); 200 | } 201 | 202 | test("If there is a value in sessionStorage, it is not re-rendered.", async () => { 203 | // Arrange 204 | const key = mockNavigation.currentEntry?.key as string; 205 | sessionStorage.setItem(`${locationKeyPrefix}${key}`, `{"count":2}`); 206 | // Act 207 | renderWithUser(); 208 | // Assert 209 | await waitFor(() => 210 | expect(screen.getByRole("heading")).toHaveTextContent("count: 0"), 211 | ); 212 | }); 213 | 214 | test("Always get the latest values.", async () => { 215 | // Arrange 216 | const key = mockNavigation.currentEntry?.key as string; 217 | sessionStorage.setItem(`${locationKeyPrefix}${key}`, `{"count":2}`); 218 | const { user } = renderWithUser(); 219 | // Act 220 | await user.click( 221 | await screen.findByRole("button", { name: "force render" }), 222 | ); 223 | // Assert 224 | expect(screen.getByRole("heading")).toHaveTextContent("count: 2"); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /packages/location-state-core/src/provider.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode, useEffect, useState } from "react"; 2 | import { LocationStateContext } from "./context"; 3 | import { StorageStore, type Store, URLStore } from "./stores"; 4 | import { NavigationSyncer } from "./syncers"; 5 | import type { Syncer } from "./types"; 6 | 7 | export type Stores = Record; 8 | type DefaultStores = { 9 | session: Store; 10 | url: Store; 11 | }; 12 | export type CreateStores = (syncer: Syncer) => S; 13 | 14 | export const createDefaultStores: CreateStores = (syncer) => ({ 15 | session: new StorageStore(globalThis.sessionStorage), 16 | url: new URLStore(syncer), 17 | }); 18 | 19 | export function LocationStateProvider({ 20 | children, 21 | ...props 22 | }: { 23 | syncer?: Syncer; 24 | stores?: Stores | CreateStores; 25 | children: ReactNode; 26 | }) { 27 | // Generated on first render to prevent provider from re-rendering 28 | const [contextValue] = useState(() => { 29 | const syncer = 30 | props.syncer ?? 31 | new NavigationSyncer( 32 | typeof window !== "undefined" ? window.navigation : undefined, 33 | ); 34 | const stores = props.stores ?? createDefaultStores; 35 | return { 36 | syncer, 37 | stores: typeof stores === "function" ? stores(syncer) : stores, 38 | }; 39 | }); 40 | const syncer = contextValue.syncer; 41 | 42 | useEffect(() => { 43 | const abortController = new AbortController(); 44 | const { signal } = abortController; 45 | const applyAllStore = (callback: (store: Store) => void) => { 46 | Object.values(contextValue.stores).forEach(callback); 47 | }; 48 | 49 | const key = syncer.key()!; 50 | applyAllStore((store) => store.load(key)); 51 | 52 | syncer.sync({ 53 | listener: (key) => { 54 | applyAllStore((store) => { 55 | store.save(); 56 | store.load(key); 57 | }); 58 | }, 59 | signal, 60 | }); 61 | window?.addEventListener( 62 | "beforeunload", 63 | () => { 64 | applyAllStore((store) => store.save()); 65 | }, 66 | { signal }, 67 | ); 68 | 69 | return () => abortController.abort(); 70 | }, [syncer, contextValue.stores]); 71 | 72 | return ( 73 | 74 | {children} 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /packages/location-state-core/src/stores/event-emitter.ts: -------------------------------------------------------------------------------- 1 | import type { Listener } from "./types"; 2 | 3 | export class EventEmitter { 4 | private readonly listeners: Map> = new Map(); 5 | 6 | on(event: string, listener: Listener) { 7 | const listeners = this.listeners.get(event); 8 | if (listeners) { 9 | listeners.add(listener); 10 | } else { 11 | this.listeners.set(event, new Set([listener])); 12 | } 13 | } 14 | 15 | off(event: string, listener: Listener) { 16 | const listeners = this.listeners.get(event); 17 | listeners?.delete(listener); 18 | if (listeners?.size === 0) { 19 | this.listeners.delete(event); 20 | } 21 | } 22 | 23 | emit(event: string) { 24 | this.listeners.get(event)?.forEach((listener) => listener()); 25 | } 26 | 27 | emitAll() { 28 | this.listeners.forEach((listeners) => 29 | listeners.forEach((listener) => listener()), 30 | ); 31 | } 32 | 33 | deferEmitAll() { 34 | queueMicrotask(() => this.emitAll()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/location-state-core/src/stores/in-memory-store.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, vi } from "vitest"; 2 | import { InMemoryStore } from "./in-memory-store"; 3 | 4 | test("The initial value is undefined.", () => { 5 | // Arrange 6 | const store = new InMemoryStore(); 7 | // Act 8 | const slice = store.get("foo"); 9 | // Assert 10 | expect(slice).toBeUndefined(); 11 | }); 12 | 13 | test("After updating a slice, the updated value can be obtained.", () => { 14 | // Arrange 15 | const store = new InMemoryStore(); 16 | // Act 17 | store.set("foo", "updated"); 18 | // Assert 19 | expect(store.get("foo")).toBe("updated"); 20 | }); 21 | 22 | test("listener is called when updating slice.", () => { 23 | // Arrange 24 | const store = new InMemoryStore(); 25 | const listener = vi.fn(); 26 | store.subscribe("foo", listener); 27 | // Act 28 | store.set("foo", "updated"); 29 | // Assert 30 | expect(listener).toBeCalledTimes(1); 31 | }); 32 | 33 | test("listener is called even if updated with undefined.", () => { 34 | // Arrange 35 | const store = new InMemoryStore(); 36 | store.set("foo", "updated"); 37 | const listener = vi.fn(); 38 | store.subscribe("foo", listener); 39 | // Act 40 | store.set("foo", undefined); 41 | // Assert 42 | expect(listener).toBeCalledTimes(1); 43 | }); 44 | 45 | test("store.get in the listener to get the latest value.", () => { 46 | // Arrange 47 | expect.assertions(4); 48 | const store = new InMemoryStore(); 49 | const listener1 = vi.fn(() => { 50 | expect(store.get("foo")).toBe("updated"); 51 | }); 52 | const listener2 = vi.fn(() => { 53 | expect(store.get("foo")).toBe("updated"); 54 | }); 55 | store.subscribe("foo", listener1); 56 | store.subscribe("foo", listener2); 57 | // Act 58 | store.set("foo", "updated"); 59 | // Assert 60 | expect(listener1).toBeCalledTimes(1); 61 | expect(listener2).toBeCalledTimes(1); 62 | }); 63 | 64 | test("The listener is unsubscribed by the returned callback, it will no longer be called when the slice is updated.", () => { 65 | // Arrange 66 | const store = new InMemoryStore(); 67 | const listener = vi.fn(); 68 | const unsubscribe = store.subscribe("foo", listener); 69 | // Act 70 | unsubscribe(); 71 | store.set("foo", "updated"); 72 | // Assert 73 | expect(listener).toBeCalledTimes(0); 74 | }); 75 | 76 | test("The slice is undefined when `load` is called without `set`.", () => { 77 | // Arrange 78 | const store = new InMemoryStore(); 79 | // Act 80 | store.load("initial"); 81 | // Assert 82 | expect(store.get("foo")).toBeUndefined(); 83 | }); 84 | 85 | test("The slice is merged when `load` is called after `set`.", () => { 86 | // Arrange 87 | const store = new InMemoryStore(); 88 | store.set("foo", 0); 89 | // Act 90 | store.load("initial"); 91 | // Assert 92 | expect(store.get("foo")).toBe(0); 93 | expect(store.get("bar")).toBeUndefined(); 94 | }); 95 | 96 | test("The slice is reset when `load` is called with different key.", () => { 97 | // Arrange 98 | const store = new InMemoryStore(); 99 | store.load("initial"); 100 | store.set("foo", "updated"); 101 | // Act 102 | store.load("second"); 103 | // Assert 104 | expect(store.get("foo")).toBeUndefined(); 105 | }); 106 | 107 | test("When the key is restored, the slice value is also restored.", () => { 108 | // Arrange 109 | const store = new InMemoryStore(); 110 | store.load("initial"); 111 | store.set("foo", "updated initial"); 112 | store.save(); 113 | store.load("second"); 114 | store.set("foo", "updated second"); 115 | // Act 116 | store.load("initial"); 117 | // Assert 118 | expect(store.get("foo")).toBe("updated initial"); 119 | }); 120 | 121 | test("On `load` called, all listener notified.", async () => { 122 | // Arrange 123 | const store = new InMemoryStore(); 124 | const listener1 = vi.fn(); 125 | const listener2 = vi.fn(); 126 | store.subscribe("foo", listener1); 127 | store.subscribe("foo", listener2); 128 | // Act 129 | store.load("initial"); 130 | // Generate and execute microtasks with Promise to wait for listener execution. 131 | await Promise.resolve(); 132 | // Assert 133 | expect(listener1).toBeCalledTimes(1); 134 | expect(listener2).toBeCalledTimes(1); 135 | }); 136 | -------------------------------------------------------------------------------- /packages/location-state-core/src/stores/in-memory-store.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "./event-emitter"; 2 | import type { Listener, Store } from "./types"; 3 | 4 | type State = Record; 5 | 6 | export class InMemoryStore implements Store { 7 | private state: Record = {}; 8 | private events = new EventEmitter(); 9 | private currentKey: string | null = null; 10 | 11 | constructor(private readonly storage: Map = new Map()) {} 12 | 13 | subscribe(name: string, listener: Listener) { 14 | this.events.on(name, listener); 15 | return () => this.events.off(name, listener); 16 | } 17 | 18 | get(name: string) { 19 | return this.state[name]; 20 | } 21 | 22 | set(name: string, value: unknown) { 23 | if (typeof value === "undefined") { 24 | delete this.state[name]; 25 | } else { 26 | this.state[name] = value; 27 | } 28 | this.events.emit(name); 29 | } 30 | 31 | load(locationKey: string) { 32 | if (this.currentKey === locationKey) return; 33 | const storageState = this.storage.get(locationKey) ?? {}; 34 | // Initial key is `null`, so we need to merge the state with the existing state. 35 | // Because it may be set before load. 36 | if (this.currentKey === null) { 37 | this.state = { 38 | ...storageState, 39 | ...this.state, 40 | }; 41 | } else { 42 | this.state = storageState; 43 | } 44 | this.currentKey = locationKey; 45 | this.events.deferEmitAll(); 46 | } 47 | 48 | save() { 49 | if (!this.currentKey) { 50 | return; 51 | } 52 | if (Object.keys(this.state).length === 0) { 53 | this.storage.delete(this.currentKey); 54 | return; 55 | } 56 | this.storage.set(this.currentKey, this.state); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/location-state-core/src/stores/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./storage-store"; 2 | export * from "./url-store"; 3 | export * from "./in-memory-store"; 4 | export * from "./types"; 5 | export * from "./serializer"; 6 | -------------------------------------------------------------------------------- /packages/location-state-core/src/stores/serializer.ts: -------------------------------------------------------------------------------- 1 | import type { StateSerializer } from "./types"; 2 | 3 | export const jsonSerializer: StateSerializer = { 4 | serialize: JSON.stringify, 5 | deserialize: JSON.parse, 6 | }; 7 | -------------------------------------------------------------------------------- /packages/location-state-core/src/stores/storage-store.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "./event-emitter"; 2 | import { jsonSerializer } from "./serializer"; 3 | import type { Listener, StateSerializer, Store } from "./types"; 4 | 5 | export const locationKeyPrefix = "__location_state_"; 6 | 7 | export class StorageStore implements Store { 8 | private state: Record = {}; 9 | private events = new EventEmitter(); 10 | private currentKey: string | null = null; 11 | 12 | constructor( 13 | private readonly storage?: Storage, // Storage is undefined in SSR. 14 | private readonly stateSerializer: StateSerializer = jsonSerializer, 15 | ) {} 16 | 17 | subscribe(name: string, listener: Listener) { 18 | this.events.on(name, listener); 19 | return () => this.events.off(name, listener); 20 | } 21 | 22 | get(name: string) { 23 | return this.state[name]; 24 | } 25 | 26 | set(name: string, value: unknown) { 27 | if (typeof value === "undefined") { 28 | delete this.state[name]; 29 | } else { 30 | this.state[name] = value; 31 | } 32 | this.events.emit(name); 33 | } 34 | 35 | load(locationKey: string) { 36 | if (this.currentKey === locationKey) return; 37 | try { 38 | const value = this.storage?.getItem(toStorageKey(locationKey)) ?? null; 39 | const state = 40 | value !== null ? this.stateSerializer.deserialize(value) : {}; 41 | // Initial key is `null`, so we need to merge the state with the existing state. 42 | // Because it may be set before load. 43 | if (this.currentKey === null) { 44 | this.state = { 45 | ...state, 46 | ...this.state, 47 | }; 48 | } else { 49 | this.state = state; 50 | } 51 | } catch (e) { 52 | console.error(e); 53 | this.state = {}; 54 | } 55 | this.currentKey = locationKey; 56 | this.events.deferEmitAll(); 57 | } 58 | 59 | save() { 60 | if (!this.currentKey) { 61 | return; 62 | } 63 | if (Object.keys(this.state).length === 0) { 64 | this.storage?.removeItem(toStorageKey(this.currentKey)); 65 | return; 66 | } 67 | let value: string; 68 | try { 69 | value = this.stateSerializer.serialize(this.state); 70 | } catch (e) { 71 | console.error(e); 72 | return; 73 | } 74 | this.storage?.setItem(toStorageKey(this.currentKey), value); 75 | } 76 | } 77 | 78 | function toStorageKey(key: string) { 79 | return `${locationKeyPrefix}${key}`; 80 | } 81 | -------------------------------------------------------------------------------- /packages/location-state-core/src/stores/types.ts: -------------------------------------------------------------------------------- 1 | export type Listener = () => void; 2 | 3 | type Serialize = (value: Record) => string; 4 | type Deserialize = (value: string) => Record; 5 | 6 | export type StateSerializer = { 7 | serialize: Serialize; 8 | deserialize: Deserialize; 9 | }; 10 | 11 | export type Store = { 12 | subscribe(name: string, listener: Listener): () => void; 13 | 14 | get(name: string): unknown; 15 | 16 | set(name: string, value: unknown): void; 17 | 18 | load(key: string): void; 19 | 20 | save(): void; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/location-state-core/src/stores/url-store.ts: -------------------------------------------------------------------------------- 1 | import type { Syncer } from "../types"; 2 | import { EventEmitter } from "./event-emitter"; 3 | import { jsonSerializer } from "./serializer"; 4 | import type { Listener, StateSerializer, Store } from "./types"; 5 | import { createThrottle } from "./utils/create-throttle"; 6 | 7 | type URLEncoder = { 8 | encode: (url: string, state?: Record) => string; 9 | decode: (url: string) => Record; 10 | }; 11 | 12 | export function searchParamEncoder( 13 | paramName: string, 14 | stateSerializer: StateSerializer, 15 | ): URLEncoder { 16 | return { 17 | encode: (url, state) => { 18 | const newUrl = new URL(url); 19 | if (state) { 20 | newUrl.searchParams.set(paramName, stateSerializer.serialize(state)); 21 | } else { 22 | newUrl.searchParams.delete(paramName); 23 | } 24 | return newUrl.toString(); 25 | }, 26 | decode: (url: string) => { 27 | const { searchParams } = new URL(url); 28 | const value = searchParams.get(paramName); 29 | return value ? stateSerializer.deserialize(value) : {}; 30 | }, 31 | }; 32 | } 33 | 34 | export const defaultSearchParamEncoder = searchParamEncoder( 35 | "location-state", 36 | jsonSerializer, 37 | ); 38 | 39 | // TODO: conform spec base URLEncoder impl 40 | 41 | export class URLStore implements Store { 42 | private state: Record = {}; 43 | private syncedURL: string | undefined; 44 | private events = new EventEmitter(); 45 | private readonly throttle = createThrottle(); 46 | 47 | constructor( 48 | private readonly syncer: Syncer, 49 | private readonly urlEncoder: URLEncoder = defaultSearchParamEncoder, 50 | ) {} 51 | 52 | subscribe(name: string, listener: Listener) { 53 | this.events.on(name, listener); 54 | return () => this.events.off(name, listener); 55 | } 56 | 57 | get(name: string) { 58 | return this.state[name]; 59 | } 60 | 61 | set(name: string, value: unknown) { 62 | if (typeof value === "undefined") { 63 | delete this.state[name]; 64 | } else { 65 | this.state[name] = value; 66 | } 67 | 68 | try { 69 | // save to url 70 | const syncedURL = this.urlEncoder.encode(location.href, this.state); 71 | this.syncedURL = syncedURL; 72 | this.throttle(() => this.syncer.updateURL(syncedURL)); 73 | } catch (e) { 74 | console.error(e); 75 | } 76 | 77 | this.events.emit(name); 78 | } 79 | 80 | load() { 81 | const currentURL = location.href; 82 | if (currentURL === this.syncedURL) return; 83 | 84 | try { 85 | this.state = this.urlEncoder.decode(currentURL); 86 | this.syncedURL = currentURL; 87 | } catch (e) { 88 | console.error(e); 89 | this.state = {}; 90 | // remove invalid state from url. 91 | const url = this.urlEncoder.encode(currentURL); 92 | this.syncer.updateURL(url); 93 | this.syncedURL = url; 94 | } 95 | 96 | this.events.deferEmitAll(); 97 | } 98 | 99 | save() { 100 | // `set` to save it in the URL, so it does nothing. 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/location-state-core/src/stores/utils/create-throttle.ts: -------------------------------------------------------------------------------- 1 | export function createThrottle() { 2 | let delayExecutedCallback: (() => void) | null = null; 3 | let isThrottleRunning = false; 4 | 5 | return (callback: () => void) => { 6 | if (isThrottleRunning) { 7 | delayExecutedCallback = callback; 8 | return; 9 | } 10 | isThrottleRunning = true; 11 | // execute immediately on first call 12 | callback(); 13 | handleTimeout(exponentialTimeout()); 14 | }; 15 | 16 | function handleTimeout(timeoutGenerator: Generator) { 17 | const timeout = timeoutGenerator.next().value as number; 18 | // If over 1000ms and no delayExecutedCallback, stop the timer and reset the throttle. 19 | if (timeout === 1000 && !delayExecutedCallback) { 20 | isThrottleRunning = false; 21 | return; 22 | } 23 | if (delayExecutedCallback) { 24 | delayExecutedCallback(); 25 | delayExecutedCallback = null; 26 | } 27 | setTimeout(() => handleTimeout(timeoutGenerator), timeout); 28 | } 29 | } 30 | 31 | function* exponentialTimeout() { 32 | yield* [50, 100, 200, 500]; 33 | while (true) { 34 | yield 1000; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/location-state-core/src/syncers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./navigation-syncer"; 2 | -------------------------------------------------------------------------------- /packages/location-state-core/src/syncers/navigation-syncer.test.ts: -------------------------------------------------------------------------------- 1 | import { createNavigationMock } from "@repo/test-utils"; 2 | import { expect, test, vi } from "vitest"; 3 | import { NavigationSyncer } from "./navigation-syncer"; 4 | 5 | test("Key changes when `navigation.currentEntry` changes.", () => { 6 | // Arrange 7 | const navigation = createNavigationMock("/"); 8 | const navigationSyncer = new NavigationSyncer(navigation); 9 | const key1 = navigationSyncer.key(); 10 | navigation.navigate("/hoge"); 11 | // Act 12 | const key2 = navigationSyncer.key(); 13 | // Assert 14 | expect(key1).not.toBeUndefined(); 15 | expect(key2).not.toBeUndefined(); 16 | expect(key1).not.toBe(key2); 17 | }); 18 | 19 | test("Listener is called when `currententrychange` event and `event.navigationType` is `push`.", () => { 20 | // Arrange 21 | const navigation = createNavigationMock("/"); 22 | const navigationSyncer = new NavigationSyncer(navigation); 23 | const listener1 = vi.fn(); 24 | const listener2 = vi.fn(); 25 | navigationSyncer.sync({ 26 | listener: listener1, 27 | signal: new AbortController().signal, 28 | }); 29 | navigationSyncer.sync({ 30 | listener: listener2, 31 | signal: new AbortController().signal, 32 | }); 33 | // Act 34 | navigation.navigate("/hoge"); 35 | // Assert 36 | expect(listener1).toHaveBeenCalledTimes(1); 37 | expect(listener2).toHaveBeenCalledTimes(1); 38 | }); 39 | 40 | test("Listener is called when `currententrychange` event and `event.navigationType` is `replace`.", () => { 41 | // Arrange 42 | const navigation = createNavigationMock("/"); 43 | const navigationSyncer = new NavigationSyncer(navigation); 44 | const listener1 = vi.fn(); 45 | const listener2 = vi.fn(); 46 | navigationSyncer.sync({ 47 | listener: listener1, 48 | signal: new AbortController().signal, 49 | }); 50 | navigationSyncer.sync({ 51 | listener: listener2, 52 | signal: new AbortController().signal, 53 | }); 54 | // Act 55 | navigation.navigate("/hoge", { history: "replace" }); 56 | // Assert 57 | expect(listener1).toHaveBeenCalledTimes(1); 58 | expect(listener2).toHaveBeenCalledTimes(1); 59 | }); 60 | 61 | test("Listener is not called when `currententrychange` event and `event.navigationType` is `reload`.", () => { 62 | // Arrange 63 | const navigation = createNavigationMock("/"); 64 | const navigationSyncer = new NavigationSyncer(navigation); 65 | const listener = vi.fn(); 66 | navigationSyncer.sync({ 67 | listener, 68 | signal: new AbortController().signal, 69 | }); 70 | // Act 71 | navigation.reload(); 72 | // Assert 73 | expect(listener).not.toHaveBeenCalled(); 74 | }); 75 | 76 | // abort does not work well, but the cause is unknown 77 | test("After `abort`, listener is called when `currententrychange` event and `event.navigationType` is `push`.", () => { 78 | // Arrange 79 | const navigation = createNavigationMock("/"); 80 | const navigationSyncer = new NavigationSyncer(navigation); 81 | const listener1 = vi.fn(); 82 | const listener2 = vi.fn(); 83 | const controller = new AbortController(); 84 | navigationSyncer.sync({ listener: listener1, signal: controller.signal }); 85 | navigationSyncer.sync({ 86 | listener: listener2, 87 | signal: new AbortController().signal, 88 | }); 89 | controller.abort(); 90 | // Act 91 | navigation.navigate("/hoge"); 92 | // Assert 93 | expect(listener1).not.toHaveBeenCalled(); 94 | expect(listener2).toHaveBeenCalled(); 95 | }); 96 | 97 | test("When `updateURL` called, navigation.navigate` is called with replace specified.", () => { 98 | // Arrange 99 | history.replaceState({ foo: "bar" }, "", "/"); 100 | const navigation = createNavigationMock("/"); 101 | const navigationSyncer = new NavigationSyncer(navigation); 102 | // Act 103 | navigationSyncer.updateURL("/hoge"); 104 | // Assert 105 | expect(globalThis.history.state).toEqual({ 106 | foo: "bar", 107 | }); 108 | expect(location.href).toBe("http://localhost:3000/hoge"); 109 | }); 110 | -------------------------------------------------------------------------------- /packages/location-state-core/src/syncers/navigation-syncer.ts: -------------------------------------------------------------------------------- 1 | import type { Syncer } from "../types"; 2 | 3 | export class NavigationSyncer implements Syncer { 4 | constructor(private readonly navigation?: Navigation) {} 5 | 6 | key(): string | undefined { 7 | return this.navigation?.currentEntry?.key; 8 | } 9 | 10 | sync({ 11 | listener, 12 | signal, 13 | }: { 14 | listener: (key: string) => void; 15 | signal: AbortSignal; 16 | }): void { 17 | let prevKey: string; 18 | this.navigation?.addEventListener( 19 | "currententrychange", 20 | (e) => { 21 | const { navigationType } = e; 22 | if (navigationType !== "push" && navigationType !== "replace") { 23 | return; 24 | } 25 | // Since an Entry always exists at the time of `currententrychange, it is non-null. 26 | const currentKey = this.key()!; 27 | if (prevKey === currentKey) { 28 | // `history.replace` may cause events to fire with the same key. 29 | // https://github.com/WICG/navigation-api#the-current-entry 30 | return; 31 | } 32 | 33 | listener(currentKey); 34 | prevKey = currentKey; 35 | }, 36 | { 37 | signal, 38 | }, 39 | ); 40 | } 41 | 42 | updateURL(url: string): void { 43 | globalThis.history.replaceState(globalThis.history.state, "", url); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/location-state-core/src/types.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export type DefaultStoreName = "session" | "url"; 4 | 5 | export type Syncer = { 6 | key(): string | undefined; 7 | sync(arg: { listener: (key: string) => void; signal: AbortSignal }): void; 8 | updateURL(url: string): void; 9 | }; 10 | -------------------------------------------------------------------------------- /packages/location-state-core/src/unsafe-navigation.ts: -------------------------------------------------------------------------------- 1 | import "client-only"; 2 | 3 | export const unsafeNavigation = 4 | typeof window === "undefined" 5 | ? undefined 6 | : window.navigation 7 | ? window.navigation 8 | : installUnsafeNavigation(); 9 | 10 | function installUnsafeNavigation(): Navigation { 11 | const originalHistory = window.history; 12 | const originalPushState = window.history.pushState.bind(window.history); 13 | const originalReplaceState = window.history.replaceState.bind(window.history); 14 | 15 | if (!originalHistory.state) { 16 | originalReplaceState( 17 | { ___UNSAFE_NAVIGATION_KEY___: crypto.randomUUID() }, 18 | "", 19 | location.href, 20 | ); 21 | } 22 | 23 | window.history.pushState = (state, unused, url) => { 24 | originalPushState( 25 | { ___UNSAFE_NAVIGATION_KEY___: crypto.randomUUID(), ...state }, 26 | unused, 27 | url, 28 | ); 29 | notify("currententrychange", { 30 | navigationType: "push", 31 | } as NavigationCurrentEntryChangeEvent); 32 | }; 33 | 34 | window.history.replaceState = (state, unused, url) => { 35 | const { ___UNSAFE_NAVIGATION_KEY___ } = originalHistory.state ?? {}; 36 | originalReplaceState( 37 | { ...state, ___UNSAFE_NAVIGATION_KEY___ }, 38 | unused, 39 | url, 40 | ); 41 | notify("currententrychange", { 42 | navigationType: "replace", 43 | } as NavigationCurrentEntryChangeEvent); 44 | }; 45 | 46 | const listenersMap = new Map>(); 47 | 48 | const addEventListener: ( 49 | type: string, 50 | listener: EventListener, 51 | options?: boolean | AddEventListenerOptions | undefined, 52 | ) => void = (type, listener, options) => { 53 | if (!listener) return; 54 | const listeners = listenersMap.get(type); 55 | if (listeners) { 56 | listeners.add(listener); 57 | } else { 58 | listenersMap.set(type, new Set([listener])); 59 | } 60 | if (options && typeof options === "object" && options.signal) { 61 | options.signal.addEventListener("abort", () => 62 | removeEventListener(type, listener, options), 63 | ); 64 | } 65 | }; 66 | 67 | const removeEventListener: ( 68 | type: string, 69 | listener: EventListener, 70 | options?: boolean | AddEventListenerOptions | undefined, 71 | ) => void = (type, listener, options) => { 72 | if (!listener) return; 73 | const listeners = listenersMap.get(type); 74 | if (!listeners) return; 75 | listeners.delete(listener); 76 | if (listeners.size === 0) { 77 | listenersMap.delete(type); 78 | } 79 | }; 80 | 81 | const notify = (type: string, event: Event) => { 82 | const listeners = listenersMap.get("currententrychange"); 83 | listeners?.forEach((listener) => { 84 | listener(event); 85 | }); 86 | }; 87 | 88 | const navigation = { 89 | addEventListener, 90 | removeEventListener, 91 | }; 92 | Object.defineProperty(navigation, "currentEntry", { 93 | configurable: true, 94 | enumerable: true, 95 | get: () => { 96 | const { ___UNSAFE_NAVIGATION_KEY___ } = originalHistory.state ?? {}; 97 | return { 98 | key: ___UNSAFE_NAVIGATION_KEY___ ?? crypto.randomUUID(), 99 | }; 100 | }, 101 | }); 102 | Object.defineProperty(window, "navigation", { 103 | configurable: true, 104 | enumerable: true, 105 | get: () => navigation, 106 | }); 107 | 108 | return navigation as Navigation; 109 | } 110 | -------------------------------------------------------------------------------- /packages/location-state-core/tsconfig.dts.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/configs/tsconfig.dts.json", 3 | "include": ["src/**/*.ts", "src/**/*.tsx"], 4 | "exclude": ["node_modules", "src/**/*.test.ts", "src/**/*.test.tsx"], 5 | "compilerOptions": { 6 | "declarationDir": "types" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/location-state-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/configs/tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vitest/globals"] 5 | }, 6 | "include": ["src/**/*.ts", "src/**/*.tsx", "./vitest.setup.ts"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/location-state-core/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | target: "es2020", 5 | format: ["cjs", "esm"], 6 | clean: true, 7 | // CI上ではdts生成より先にbuildが進んでしまうため、以下のissue解消後有効化 8 | // https://github.com/egoist/tsup/issues/921 9 | dts: false, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/location-state-core/vitest.config.mts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | test: { 7 | globals: true, 8 | environment: "jsdom", 9 | include: ["src/**/*.test.{ts,tsx}"], 10 | setupFiles: "./vitest.setup.ts", 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/location-state-core/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | -------------------------------------------------------------------------------- /packages/location-state-next/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @location-state/next 2 | 3 | ## 1.2.2 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies [b6cd496] 8 | - @location-state/core@1.2.2 9 | 10 | ## 1.2.1 11 | 12 | ### Patch Changes 13 | 14 | - @location-state/core@1.2.1 15 | 16 | ## 1.2.0 17 | 18 | ### Patch Changes 19 | 20 | - Updated dependencies [518d0ae] 21 | - @location-state/core@1.2.0 22 | 23 | ## 1.1.0 24 | 25 | ### Patch Changes 26 | 27 | - 5b909c1: fix `next/router` to `next/router.js`. 28 | - Updated dependencies [b69c437] 29 | - Updated dependencies [0bad20c] 30 | - @location-state/core@1.1.0 31 | 32 | ## 1.0.1 33 | 34 | ### Patch Changes 35 | 36 | - 7e061fc: Initial release. 37 | - Updated dependencies [7e061fc] 38 | - @location-state/core@1.0.1 39 | -------------------------------------------------------------------------------- /packages/location-state-next/README.md: -------------------------------------------------------------------------------- 1 | # `@location-state/next` 2 | 3 | [![npm version](https://badge.fury.io/js/@location-state%2Fnext.svg)](https://badge.fury.io/js/@location-state%2Fnext) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | 6 | State management library for React that synchronizes with history location supporting Next.js Pages Router. 7 | 8 | ## Features 9 | 10 | - Manage the state to synchronize with the history location. 11 | - By default, supports Session Storage and URL as persistent destinations. 12 | 13 | ## Packages 14 | 15 | - [@location-state/core](/packages/location-state-core/README.md): Framework agnostic, but for Next.js App Router. 16 | - [@location-state/next](/packages/location-state-next/README.md): For Next.js Pages Router. 17 | 18 | ## Quickstart for Next.js [Pages Router](https://nextjs.org/docs/pages) 19 | 20 | ### Installation 21 | 22 | ```sh 23 | npm install @location-state/core @location-state/next 24 | # or 25 | yarn add @location-state/core @location-state/next 26 | # or 27 | pnpm add @location-state/core @location-state/next 28 | ``` 29 | 30 | ### Configuration 31 | 32 | ```tsx 33 | // src/pages/_app.tsx 34 | import { LocationStateProvider } from "@location-state/core"; 35 | import { useNextPagesSyncer } from "@location-state/next"; 36 | import type { AppProps } from "next/app"; 37 | 38 | export default function MyApp({ Component, pageProps }: AppProps) { 39 | const syncer = useNextPagesSyncer(); 40 | return ( 41 | 42 | 43 | 44 | ); 45 | } 46 | ``` 47 | 48 | ### Working with state 49 | 50 | ```tsx 51 | import { useLocationState } from "@location-state/core"; 52 | 53 | export function Counter() { 54 | const [counter, setCounter] = useLocationState({ 55 | name: "counter", 56 | defaultValue: 0, 57 | storeName: "session", 58 | }); 59 | 60 | return ( 61 |
62 |

counter: {counter}

63 | 64 |
65 | ); 66 | } 67 | ``` 68 | 69 | ## API 70 | 71 | View the API reference [here](./docs/API.md). 72 | -------------------------------------------------------------------------------- /packages/location-state-next/docs/API.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | - [Syncer hooks](#Syncer-hooks) 4 | - [function `useNextPagesSyncer`](#function-useNextPagesSyncer) 5 | 6 | ## Syncer hooks 7 | 8 | Syncer hooks are provided [`syncer`](/packages/location-state-core/docs/API.md#Syncer). 9 | 10 | ### function `useNextPagesSyncer` 11 | 12 | ```ts 13 | declare function useNextPagesSyncer(): Syncer; 14 | ``` 15 | 16 | Provides a [`Syncer`](/packages/location-state-core/docs/API.md#Syncer) instance. This hook is used in Next.js Pages Router to sync the location state. 17 | 18 | #### Returns 19 | 20 | Returns a `Syncer` instance. 21 | 22 | #### Example 23 | 24 | ```tsx 25 | const syncer = useNextPagesSyncer(); 26 | ``` 27 | -------------------------------------------------------------------------------- /packages/location-state-next/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@location-state/next", 3 | "version": "1.2.2", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "repository": "https://github.com/recruit-tech/location-state", 8 | "description": "State management library for React that synchronizes with history entries supporting Next.js Pages Router.", 9 | "files": [ 10 | "dist", 11 | "types", 12 | "src", 13 | "package.json" 14 | ], 15 | "exports": { 16 | ".": { 17 | "require": "./dist/index.js", 18 | "import": "./dist/index.mjs", 19 | "types": "./types/index.d.ts" 20 | } 21 | }, 22 | "scripts": { 23 | "dev": "pnpm dev:build & pnpm dev:dts", 24 | "dev:build": "tsup src/index.ts --watch", 25 | "dev:dts": "tsc -p tsconfig.dts.json --watch", 26 | "build": "tsup src/index.ts", 27 | "dts": "tsc -p tsconfig.dts.json", 28 | "typecheck": "tsc" 29 | }, 30 | "dependencies": { 31 | "@location-state/core": "workspace:*" 32 | }, 33 | "devDependencies": { 34 | "@location-state/core": "workspace:*", 35 | "@repo/configs": "workspace:*", 36 | "@types/react": "19.1.5", 37 | "navigation-api-types": "0.6.1", 38 | "next": "15.3.3", 39 | "react": "19.1.0" 40 | }, 41 | "peerDependencies": { 42 | "next": "^13.0.0 || ^14.0.0 || ^15.0.0", 43 | "react": "^18.2.0 || ^19.0.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/location-state-next/src/hooks.ts: -------------------------------------------------------------------------------- 1 | import type { Syncer } from "@location-state/core"; 2 | import { Router, useRouter } from "next/router.js"; 3 | import React from "react"; 4 | import { NextPagesSyncer } from "./next-pages-syncer"; 5 | 6 | export function useNextPagesSyncer(): Syncer { 7 | const router = useRouter(); 8 | const [syncer] = React.useState(() => new NextPagesSyncer(router)); 9 | const needNotify = React.useRef(false); 10 | if (needNotify.current) { 11 | syncer.notify(); 12 | needNotify.current = false; 13 | } 14 | 15 | React.useEffect(() => { 16 | const routeChangeHandler = () => { 17 | needNotify.current = true; 18 | }; 19 | Router.events.on("routeChangeStart", routeChangeHandler); 20 | return () => Router.events.off("routeChangeStart", routeChangeHandler); 21 | }, []); 22 | 23 | return syncer; 24 | } 25 | -------------------------------------------------------------------------------- /packages/location-state-next/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./hooks"; 2 | -------------------------------------------------------------------------------- /packages/location-state-next/src/next-pages-syncer.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import type { Syncer } from "@location-state/core"; 4 | import type { NextRouter } from "next/router.js"; 5 | 6 | export class NextPagesSyncer implements Syncer { 7 | private readonly listeners = new Set<(key: string) => void>(); 8 | 9 | constructor(private readonly router: NextRouter) {} 10 | 11 | key(): string | undefined { 12 | // The `history.state.key` disappears on reload or MPA transition. 13 | // Also, only the form parts are restored when Chrome's BF Cache is restored, so use Navigation API key when available. 14 | return window.navigation?.currentEntry?.key ?? window.history.state.key; 15 | } 16 | 17 | sync({ 18 | listener, 19 | signal, 20 | }: { 21 | listener: (key: string) => void; 22 | signal: AbortSignal; 23 | }): void { 24 | this.listeners.add(listener); 25 | signal?.addEventListener("abort", () => { 26 | this.listeners.delete(listener); 27 | }); 28 | } 29 | 30 | notify() { 31 | const currentKey = this.key()!; 32 | this.listeners.forEach((listener) => listener(currentKey)); 33 | } 34 | 35 | updateURL(url: string): void { 36 | this.router.replace(url, undefined, { shallow: true }); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/location-state-next/tsconfig.dts.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/configs/tsconfig.dts.json", 3 | "include": ["src/**/*.ts", "src/**/*.tsx"], 4 | "exclude": ["node_modules", ".next", "src/**/*.test.ts", "src/**/*.test.tsx"], 5 | "compilerOptions": { 6 | "declarationDir": "types" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/location-state-next/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/configs/tsconfig.json", 3 | "compilerOptions": { 4 | "plugins": [ 5 | { 6 | "name": "next" 7 | } 8 | ] 9 | }, 10 | "include": ["src/**/*.ts", "src/**/*.tsx"], 11 | "exclude": ["node_modules", ".next"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/location-state-next/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | target: "es2020", 5 | format: ["cjs", "esm"], 6 | clean: true, 7 | // CI上ではdts生成より先にbuildが進んでしまうため、以下のissue解消後有効化 8 | // https://github.com/egoist/tsup/issues/921 9 | dts: false, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/test-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/test-utils", 3 | "version": "0.0.0", 4 | "private": true, 5 | "main": "./src/index.ts", 6 | "types": "./src/index.ts", 7 | "scripts": { 8 | "build": "tsup src/index.ts", 9 | "typecheck": "tsc" 10 | }, 11 | "dependencies": { 12 | "@testing-library/react": "16.3.0", 13 | "@testing-library/user-event": "14.6.1", 14 | "uuid": "11.1.0" 15 | }, 16 | "devDependencies": { 17 | "@repo/configs": "workspace:*", 18 | "@types/uuid": "10.0.0", 19 | "navigation-api-types": "0.6.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/test-utils/src/index.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export * from "./navigation.mock"; 4 | export * from "./render"; 5 | -------------------------------------------------------------------------------- /packages/test-utils/src/navigation.mock.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | 3 | class PartialNavigationHistoryEntry implements Partial { 4 | readonly key?: string; 5 | 6 | constructor(public url?: string) { 7 | if (url) { 8 | this.key = uuidv4(); 9 | } 10 | } 11 | } 12 | 13 | class PartialNavigateEvent implements Partial { 14 | constructor(public navigationType: NavigationApiNavigationType) {} 15 | } 16 | 17 | class PartialNavigation implements Partial { 18 | currentEntry?: NavigationHistoryEntry; 19 | private listenersMap = new Map>(); 20 | 21 | constructor(public currentUrl?: string) { 22 | this.setEntryWithUrl(currentUrl); 23 | } 24 | 25 | private setEntryWithUrl(url?: string) { 26 | this.currentEntry = new PartialNavigationHistoryEntry( 27 | url, 28 | ) as NavigationHistoryEntry; 29 | } 30 | 31 | navigate(url: string, options?: NavigationNavigateOptions): NavigationResult { 32 | const { history = "push" } = options || {}; 33 | this.setEntryWithUrl(url); 34 | this.dispatchEntryChangeEvent(history as "push" | "replace"); 35 | return { 36 | // not implemented 37 | } as NavigationResult; 38 | } 39 | 40 | reload() { 41 | this.setEntryWithUrl(this.currentEntry?.url!); 42 | this.dispatchEntryChangeEvent("reload"); 43 | return { 44 | // not implemented 45 | } as NavigationResult; 46 | } 47 | 48 | private dispatchEntryChangeEvent(type: NavigationApiNavigationType) { 49 | const event = new PartialNavigateEvent(type) as NavigateEvent; 50 | const listeners = this.listenersMap.get("currententrychange"); 51 | if (listeners) { 52 | listeners.forEach((listener) => listener(event)); 53 | } 54 | } 55 | 56 | addEventListener( 57 | type: string, 58 | listener: EventListener, 59 | { signal }: AddEventListenerOptions = {}, 60 | ) { 61 | if (type !== "currententrychange") throw new Error("Not implemented"); 62 | if (this.listenersMap.has(type)) { 63 | this.listenersMap.get(type)?.add(listener); 64 | } else { 65 | this.listenersMap.set(type, new Set([listener])); 66 | } 67 | 68 | signal?.addEventListener("abort", () => { 69 | this.listenersMap.get(type)?.delete(listener); 70 | }); 71 | } 72 | } 73 | 74 | export const createNavigationMock = (url?: string) => 75 | new PartialNavigation(url) as unknown as Navigation; 76 | -------------------------------------------------------------------------------- /packages/test-utils/src/render.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | 4 | export function renderWithUser(jsx: React.ReactElement) { 5 | return { 6 | user: userEvent.setup(), 7 | ...render(jsx), 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /packages/test-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/configs/tsconfig.json", 3 | "include": ["src/**/*.ts", "src/**/*.tsx"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/test-utils/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | target: "es2020", 5 | clean: true, 6 | // CI上ではdts生成より先にbuildが進んでしまうため、以下のissue解消後有効化 7 | // https://github.com/egoist/tsup/issues/921 8 | dts: false, 9 | }); 10 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@repo/utils", 3 | "version": "0.0.0", 4 | "private": true, 5 | "exports": { 6 | "./asserts": "./src/asserts.ts", 7 | "./type": "./src/type.ts" 8 | }, 9 | "scripts": {}, 10 | "devDependencies": { 11 | "@repo/configs": "workspace:*" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/utils/src/asserts.ts: -------------------------------------------------------------------------------- 1 | class AssertsError extends Error { 2 | constructor(message: string) { 3 | super(`Assert Error: ${message}`); 4 | this.name = "AssertsError"; 5 | } 6 | } 7 | 8 | export function assertRecord( 9 | value: unknown, 10 | ): asserts value is Record { 11 | if (value === null) { 12 | throw new AssertsError("Expected object but got null"); 13 | } 14 | if (Array.isArray(value)) { 15 | throw new AssertsError("Expected object but got array"); 16 | } 17 | if (typeof value !== "object") { 18 | throw new AssertsError(`Expected object but got ${typeof value}`); 19 | } 20 | } 21 | 22 | export function assertArray(value: unknown): asserts value is Array { 23 | if (!Array.isArray(value)) { 24 | throw new AssertsError(`Expected array but got ${typeof value}`); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/utils/src/type.ts: -------------------------------------------------------------------------------- 1 | export type Pretty = { 2 | [K in keyof T]: T[K]; 3 | } & {}; 4 | 5 | export type DeepPartial = T extends object 6 | ? { 7 | [P in keyof T]?: DeepPartial; 8 | } 9 | : T; 10 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/configs/tsconfig.json", 3 | "include": ["src/**/*.ts", "src/**/*.tsx"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | - "apps/*" 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:base"], 4 | "timezone": "Asia/Tokyo", 5 | "schedule": ["after 10pm and before 5am"], 6 | "dependencyDashboard": false, 7 | "automerge": true, 8 | "platformAutomerge": true, 9 | "separateMultipleMajor": true, 10 | "packageRules": [ 11 | { 12 | "matchPackagePatterns": ["^@conform-to/"], 13 | "groupName": "conform packages" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "tui", 4 | "tasks": { 5 | "dev": { 6 | "cache": false, 7 | "persistent": true 8 | }, 9 | "dts": { 10 | "dependsOn": ["^dts"], 11 | "outputs": ["types/**"] 12 | }, 13 | "build": { 14 | "dependsOn": ["dts", "^build"], 15 | "outputs": ["dist/**", ".next/**"] 16 | }, 17 | "typecheck": { 18 | "dependsOn": ["dts"] 19 | }, 20 | "integration-test": {}, 21 | "test": { 22 | "outputs": ["coverage/**"] 23 | } 24 | }, 25 | "globalDependencies": ["**/tsconfig.json"] 26 | } 27 | --------------------------------------------------------------------------------