@scrabble-solver/scrabble-solver 2.16.7 → 2.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +13 -13
  3. package/.next/cache/.previewinfo +1 -1
  4. package/.next/cache/.rscinfo +1 -1
  5. package/.next/cache/.tsbuildinfo +1 -1
  6. package/.next/cache/webpack/client-production/0.pack +0 -0
  7. package/.next/cache/webpack/client-production/index.pack +0 -0
  8. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  9. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  10. package/.next/cache/webpack/edge-server-production/index.pack.old +0 -0
  11. package/.next/cache/webpack/server-production/0.pack +0 -0
  12. package/.next/cache/webpack/server-production/index.pack +0 -0
  13. package/.next/cache/webpack/server-production/index.pack.old +0 -0
  14. package/.next/diagnostics/framework.json +1 -1
  15. package/.next/next-server.js.nft.json +1 -1
  16. package/.next/prerender-manifest.json +4 -4
  17. package/.next/required-server-files.js +3 -2
  18. package/.next/required-server-files.json +3 -2
  19. package/.next/routes-manifest.json +1 -1
  20. package/.next/server/chunks/129.js +1 -0
  21. package/.next/server/chunks/30.js +1 -1
  22. package/.next/server/chunks/392.js +1 -1
  23. package/.next/server/chunks/671.js +1 -1
  24. package/.next/server/chunks/712.js +1 -1
  25. package/.next/server/chunks/785.js +1 -0
  26. package/.next/server/chunks/791.js +1 -1
  27. package/.next/server/middleware-build-manifest.js +1 -1
  28. package/.next/server/pages/404.html +1 -1
  29. package/.next/server/pages/500.html +1 -1
  30. package/.next/server/pages/_app.js +1 -1
  31. package/.next/server/pages/_app.js.nft.json +1 -1
  32. package/.next/server/pages/_error.js +1 -1
  33. package/.next/server/pages/_error.js.nft.json +1 -1
  34. package/.next/server/pages/api/dictionary/[locale]/[word].js +1 -1
  35. package/.next/server/pages/api/dictionary/[locale]/[word].js.nft.json +1 -1
  36. package/.next/server/pages/api/dictionary/[locale].js +1 -1
  37. package/.next/server/pages/api/dictionary/[locale].js.nft.json +1 -1
  38. package/.next/server/pages/api/solve.js +1 -1
  39. package/.next/server/pages/api/solve.js.nft.json +1 -1
  40. package/.next/server/pages/api/verify.js +1 -1
  41. package/.next/server/pages/api/verify.js.nft.json +1 -1
  42. package/.next/server/pages/index.html +1 -1
  43. package/.next/server/pages/index.js +1 -1
  44. package/.next/server/pages/index.js.nft.json +1 -1
  45. package/.next/server/pages/index.json +1 -1
  46. package/.next/server/pages/not-found.html +1 -1
  47. package/.next/server/pages/not-found.js.nft.json +1 -1
  48. package/.next/server/pages-manifest.json +1 -1
  49. package/.next/server/webpack-runtime.js +1 -1
  50. package/.next/static/{vYpMmuC_yEurZGcyGVnLf → OLE8_ZInlGH7ENU0NIOpf}/_buildManifest.js +1 -1
  51. package/.next/static/chunks/583.7a46807be14c377e.js +1 -0
  52. package/.next/static/chunks/830.661816bf0de8c2f1.js +1 -0
  53. package/.next/static/chunks/{main-852475ca502b4e49.js → main-5b999f3f24ebfa84.js} +2 -2
  54. package/.next/static/chunks/pages/_app-7024e7a27ffbb2fa.js +7 -0
  55. package/.next/static/chunks/pages/{index-ec0f6aeeaf7be227.js → index-8b398b0a5d870773.js} +1 -1
  56. package/.next/static/chunks/webpack-3943d624bbb47a64.js +1 -0
  57. package/.next/static/css/aedeaada218d7280.css +2 -0
  58. package/.next/trace +21 -21
  59. package/.next/trace-build +1 -1
  60. package/next.config.js +0 -6
  61. package/package.json +11 -11
  62. package/src/components/Board/components/Cell/Cell.tsx +4 -1
  63. package/src/components/Board/hooks/useGrid.ts +17 -6
  64. package/src/components/Dictionary/Dictionary.module.scss +7 -22
  65. package/src/components/Dictionary/Dictionary.tsx +1 -8
  66. package/src/components/Results/Result.tsx +0 -3
  67. package/src/components/Results/Results.module.scss +0 -1
  68. package/src/components/Results/Results.tsx +10 -2
  69. package/src/components/Results/types.ts +3 -3
  70. package/src/components/Solver/Solver.tsx +4 -3
  71. package/src/components/Tooltip/useTooltip.ts +1 -1
  72. package/src/pages/api/dictionary/[locale]/[word].ts +2 -2
  73. package/src/pages/api/dictionary/[locale]/index.ts +80 -3
  74. package/src/pages/api/solve.ts +2 -2
  75. package/src/pages/api/verify.ts +12 -3
  76. package/src/sdk/solve.ts +13 -7
  77. package/src/sdk/verify.ts +12 -6
  78. package/src/service-worker/appShell.test.ts +86 -0
  79. package/src/service-worker/appShell.ts +39 -0
  80. package/src/service-worker/index.ts +4 -5
  81. package/src/serviceWorkerManager.ts +1 -18
  82. package/src/solver-worker/client.test.ts +151 -0
  83. package/src/solver-worker/client.ts +106 -0
  84. package/src/solver-worker/dictionaries/constants.ts +9 -0
  85. package/src/solver-worker/dictionaries/getDictionary.ts +21 -0
  86. package/src/solver-worker/dictionaries/index.ts +2 -0
  87. package/src/solver-worker/dictionaries/revalidateDictionary.test.ts +199 -0
  88. package/src/solver-worker/dictionaries/revalidateDictionary.ts +86 -0
  89. package/src/solver-worker/dictionaries/revalidatedAt.ts +43 -0
  90. package/src/solver-worker/getGaddag.test.ts +109 -0
  91. package/src/solver-worker/getGaddag.ts +62 -0
  92. package/src/solver-worker/index.ts +1 -0
  93. package/src/solver-worker/index.worker.ts +124 -0
  94. package/src/solver-worker/messages.ts +20 -0
  95. package/src/state/index.ts +1 -0
  96. package/src/state/sagas.ts +19 -9
  97. package/src/state/useTypedStore.ts +6 -0
  98. package/src/types/api.ts +1 -2
  99. package/tsconfig.json +4 -0
  100. package/tsconfig.tsbuildinfo +1 -1
  101. package/.next/static/chunks/pages/_app-a5bbe02710f13d05.js +0 -7
  102. package/.next/static/chunks/webpack-1a7a166b34ed8d24.js +0 -1
  103. package/.next/static/css/0af35adb4bab56f0.css +0 -2
  104. package/src/sdk/getDictionary.ts +0 -9
  105. package/src/service-worker/average.ts +0 -7
  106. package/src/service-worker/dictionaries/constants.ts +0 -5
  107. package/src/service-worker/dictionaries/getDictionary.ts +0 -20
  108. package/src/service-worker/dictionaries/index.ts +0 -2
  109. package/src/service-worker/dictionaries/revalidateDictionary.ts +0 -33
  110. package/src/service-worker/getTrie.ts +0 -24
  111. package/src/service-worker/routeSolveRequests.ts +0 -91
  112. package/src/service-worker/routeVerifyRequests.ts +0 -37
  113. /package/.next/static/{vYpMmuC_yEurZGcyGVnLf → OLE8_ZInlGH7ENU0NIOpf}/_ssgManifest.js +0 -0
  114. /package/src/{service-worker → solver-worker}/dictionaries/expirationManager.ts +0 -0
  115. /package/src/{service-worker → solver-worker}/dictionaries/getDictionaryUrl.ts +0 -0
package/.next/trace-build CHANGED
@@ -1 +1 @@
1
- [{"name":"run-typescript","duration":158871,"timestamp":7220131342,"id":13,"parentId":1,"tags":{},"startTime":1785617459374,"traceId":"5804b27c59cf8ea7"},{"name":"run-webpack","duration":5673692,"timestamp":7220292828,"id":15,"parentId":1,"tags":{},"startTime":1785617459536,"traceId":"5804b27c59cf8ea7"},{"name":"static-check","duration":212429,"timestamp":7226033524,"id":2053,"parentId":1,"tags":{},"startTime":1785617465276,"traceId":"5804b27c59cf8ea7"},{"name":"static-generation","duration":242167,"timestamp":7226254581,"id":2071,"parentId":1,"tags":{},"startTime":1785617465497,"traceId":"5804b27c59cf8ea7"},{"name":"collect-build-traces","duration":4209754,"timestamp":7226246405,"id":2068,"parentId":1,"tags":{},"startTime":1785617465489,"traceId":"5804b27c59cf8ea7"},{"name":"telemetry-flush","duration":25,"timestamp":7230457410,"id":2078,"parentId":1,"tags":{},"startTime":1785617469700,"traceId":"5804b27c59cf8ea7"},{"name":"next-build","duration":10563371,"timestamp":7219894069,"id":1,"tags":{"buildMode":"default","version":"16.3.0-preview.10","bundler":"webpack","has-custom-webpack-config":"true","use-build-worker":"false"},"startTime":1785617459137,"traceId":"5804b27c59cf8ea7"}]
1
+ [{"name":"run-typescript","duration":169366,"timestamp":26528773671,"id":13,"parentId":1,"tags":{},"startTime":1786055166126,"traceId":"c15733d8168a03e3"},{"name":"run-webpack","duration":6699896,"timestamp":26528944927,"id":15,"parentId":1,"tags":{},"startTime":1786055166298,"traceId":"c15733d8168a03e3"},{"name":"static-check","duration":225589,"timestamp":26535727438,"id":2014,"parentId":1,"tags":{},"startTime":1786055173080,"traceId":"c15733d8168a03e3"},{"name":"static-generation","duration":240386,"timestamp":26535961329,"id":2032,"parentId":1,"tags":{},"startTime":1786055173314,"traceId":"c15733d8168a03e3"},{"name":"collect-build-traces","duration":4253048,"timestamp":26535953473,"id":2029,"parentId":1,"tags":{},"startTime":1786055173306,"traceId":"c15733d8168a03e3"},{"name":"telemetry-flush","duration":19,"timestamp":26540207730,"id":2039,"parentId":1,"tags":{},"startTime":1786055177561,"traceId":"c15733d8168a03e3"},{"name":"next-build","duration":11677867,"timestamp":26528529886,"id":1,"tags":{"buildMode":"default","version":"16.3.0","bundler":"webpack","has-custom-webpack-config":"true","use-build-worker":"false"},"startTime":1786055165883,"traceId":"c15733d8168a03e3"}]
package/next.config.js CHANGED
@@ -4,12 +4,6 @@ const path = require('path');
4
4
  const WorkboxPlugin = require('workbox-webpack-plugin');
5
5
 
6
6
  module.exports = {
7
- compress: false,
8
- experimental: {
9
- // TypeScript 7 dropped the JS compiler API that Next.js's built-in type checker uses,
10
- // Remove the flag once Next.js integrates with TS7 out of the box.
11
- useTypeScriptCli: true,
12
- },
13
7
  reactStrictMode: true,
14
8
  sassOptions: {
15
9
  loadPaths: ['./src', path.join(__dirname, '../../node_modules/include-media/dist')],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrabble-solver/scrabble-solver",
3
- "version": "2.16.7",
3
+ "version": "2.17.0",
4
4
  "description": "Scrabble Solver 2 - App",
5
5
  "repository": {
6
6
  "type": "git",
@@ -27,20 +27,20 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@floating-ui/react": "^0.27.20",
30
- "@kamilmielnik/trie": "^5.1.0",
30
+ "@kamilmielnik/gaddag": "^2.0.0",
31
31
  "@reduxjs/toolkit": "^2.12.0",
32
- "@scrabble-solver/configs": "^2.16.7",
33
- "@scrabble-solver/constants": "^2.16.7",
34
- "@scrabble-solver/dictionaries": "^2.16.7",
35
- "@scrabble-solver/logger": "^2.16.7",
36
- "@scrabble-solver/solver": "^2.16.7",
37
- "@scrabble-solver/types": "^2.16.7",
38
- "@scrabble-solver/word-definitions": "^2.16.7",
32
+ "@scrabble-solver/configs": "^2.17.0",
33
+ "@scrabble-solver/constants": "^2.17.0",
34
+ "@scrabble-solver/dictionaries": "^2.17.0",
35
+ "@scrabble-solver/logger": "^2.17.0",
36
+ "@scrabble-solver/solver": "^2.17.0",
37
+ "@scrabble-solver/types": "^2.17.0",
38
+ "@scrabble-solver/word-definitions": "^2.17.0",
39
39
  "classnames": "^2.5.1",
40
40
  "env-cmd": "^11.0.0",
41
41
  "include-media": "^2.0.0",
42
42
  "include-media-query-builder": "^1.1.0",
43
- "next": "^16.3.0-preview.10",
43
+ "next": "^16.3.0",
44
44
  "normalize.css": "^8.0.1",
45
45
  "react": "^19.2.8",
46
46
  "react-cool-onclickoutside": "^1.7.0",
@@ -68,5 +68,5 @@
68
68
  "@types/react-portal": "^4.0.7",
69
69
  "sass": "^1.102.0"
70
70
  },
71
- "gitHead": "1e8a372cabe4bb977bbd0528851104bcad27d545"
71
+ "gitHead": "32234a6340029d39dde134edc9e71ff913836931"
72
72
  }
@@ -5,6 +5,7 @@ import {
5
5
  type ChangeEventHandler,
6
6
  type FocusEventHandler,
7
7
  type FunctionComponent,
8
+ memo,
8
9
  type MouseEventHandler,
9
10
  type RefObject,
10
11
  type TouchEventHandler,
@@ -41,7 +42,7 @@ interface Props {
41
42
  onFocus: (x: number, y: number) => void;
42
43
  }
43
44
 
44
- export const Cell: FunctionComponent<Props> = ({
45
+ const CellBase: FunctionComponent<Props> = ({
45
46
  cell,
46
47
  cellBottom,
47
48
  cellLeft,
@@ -135,3 +136,5 @@ export const Cell: FunctionComponent<Props> = ({
135
136
  />
136
137
  );
137
138
  };
139
+
140
+ export const Cell = memo(CellBase);
@@ -53,6 +53,7 @@ export const useGrid = (rows: Cell[][]): [State, Actions] => {
53
53
  const [activeIndex, setActiveIndex] = useState<Point>({ x: 0, y: 0 });
54
54
  const [direction, setLastDirection] = useState<Direction>('horizontal');
55
55
  const directionRef = useLatest(direction);
56
+ const rowsRef = useLatest(rows);
56
57
 
57
58
  const safeActiveIndex = useMemo(
58
59
  () => ({
@@ -94,7 +95,7 @@ export const useGrid = (rows: Cell[][]): [State, Actions] => {
94
95
  (position: Point, value: string) => {
95
96
  const characters = value ? extractCharacters(config, value).filter((character) => character !== BLANK) : [BLANK];
96
97
  const actions: PayloadAction<unknown>[] = [];
97
- let board = new Board({ rows: rows.map((row) => row.map((cell) => cell.clone())) });
98
+ let board = new Board({ rows: rowsRef.current.map((row) => row.map((cell) => cell.clone())) });
98
99
  let { x, y } = position;
99
100
 
100
101
  const scheduleMoveFocus = () => {
@@ -188,7 +189,7 @@ export const useGrid = (rows: Cell[][]): [State, Actions] => {
188
189
  moveFocus(Math.abs(position.x - x) + Math.abs(position.y - y));
189
190
  actions.forEach(dispatch);
190
191
  },
191
- [config, directionRef, dispatch, height, moveFocus, rows, width],
192
+ [config, directionRef, dispatch, height, moveFocus, rowsRef, width],
192
193
  );
193
194
 
194
195
  const onChange = useCallback(
@@ -209,7 +210,7 @@ export const useGrid = (rows: Cell[][]): [State, Actions] => {
209
210
 
210
211
  if (value === EMPTY_CELL) {
211
212
  const { x, y } = position;
212
- const cell = rows[y][x];
213
+ const cell = rowsRef.current[y][x];
213
214
 
214
215
  if (cell.hasTile()) {
215
216
  dispatch(boardSlice.actions.toggleCellIsBlank(position));
@@ -219,7 +220,7 @@ export const useGrid = (rows: Cell[][]): [State, Actions] => {
219
220
 
220
221
  insertValue(position, value);
221
222
  },
222
- [dispatch, insertValue, moveFocus, rows, getInputRefPosition],
223
+ [dispatch, insertValue, moveFocus, rowsRef, getInputRefPosition],
223
224
  );
224
225
 
225
226
  const onDirectionToggle = useCallback(() => setLastDirection(toggleDirection), []);
@@ -308,7 +309,7 @@ export const useGrid = (rows: Cell[][]): [State, Actions] => {
308
309
  return;
309
310
  }
310
311
 
311
- const cell = rows[y][x];
312
+ const cell = rowsRef.current[y][x];
312
313
 
313
314
  if (isCtrl(event) && character === 'g') {
314
315
  event.preventDefault();
@@ -347,7 +348,17 @@ export const useGrid = (rows: Cell[][]): [State, Actions] => {
347
348
  dispatch(boardSlice.actions.toggleCellIsBlank(position));
348
349
  },
349
350
  });
350
- }, [changeActiveIndex, config, direction, dispatch, getInputRefPosition, locale, moveFocus, onDirectionToggle, rows]);
351
+ }, [
352
+ changeActiveIndex,
353
+ config,
354
+ direction,
355
+ dispatch,
356
+ getInputRefPosition,
357
+ locale,
358
+ moveFocus,
359
+ onDirectionToggle,
360
+ rowsRef,
361
+ ]);
351
362
 
352
363
  const onPaste = useCallback<ClipboardEventHandler>(
353
364
  (event) => {
@@ -2,17 +2,8 @@
2
2
 
3
3
  .dictionary {
4
4
  position: relative;
5
- transition: var(--transition);
6
5
  word-break: break-word;
7
6
 
8
- &.isAllowed {
9
- background-color: var(--color--green--light);
10
- }
11
-
12
- &.isNotAllowed {
13
- background-color: var(--color--red--light);
14
- }
15
-
16
7
  &:focus-within {
17
8
  @include focus-effect;
18
9
  }
@@ -21,6 +12,8 @@
21
12
  .content {
22
13
  @include scrollbars;
23
14
 
15
+ display: flex;
16
+ flex-direction: column;
24
17
  height: 100%;
25
18
  overflow-y: auto;
26
19
  border-radius: inherit;
@@ -31,26 +24,18 @@
31
24
  }
32
25
 
33
26
  .result {
34
- transition: var(--transition);
27
+ flex-shrink: 0;
28
+
29
+ &:last-child {
30
+ flex-grow: 1;
31
+ }
35
32
 
36
33
  &.isAllowed {
37
34
  background-color: var(--color--green--light);
38
-
39
- & + & {
40
- .content {
41
- padding-top: 0;
42
- }
43
- }
44
35
  }
45
36
 
46
37
  &.isNotAllowed {
47
38
  background-color: var(--color--red--light);
48
-
49
- & + & {
50
- .content {
51
- padding-top: 0;
52
- }
53
- }
54
39
  }
55
40
  }
56
41
 
@@ -25,16 +25,9 @@ export const Dictionary: FunctionComponent<Props> = ({ className }) => {
25
25
  const results = useTypedSelector(selectDictionaryResults);
26
26
  const isLoading = useTypedSelector(selectDictionaryIsLoading);
27
27
  const error = useTypedSelector(selectDictionaryError);
28
- const isLastAllowed = results.at(-1)?.isAllowed;
29
28
 
30
29
  return (
31
- <div
32
- className={classNames(styles.dictionary, className, {
33
- [styles.isAllowed]: isLastAllowed === true,
34
- [styles.isNotAllowed]: isLastAllowed === false,
35
- })}
36
- style={{ height: dictionaryResultsHeight }}
37
- >
30
+ <div className={classNames(styles.dictionary, className)} style={{ height: dictionaryResultsHeight }}>
38
31
  <div className={styles.content}>
39
32
  {typeof error !== 'undefined' && !isLoading && <EmptyState variant="error">{error.message}</EmptyState>}
40
33
 
@@ -28,7 +28,6 @@ export const Result = ({
28
28
  onClick = noop,
29
29
  onFocus = noop,
30
30
  onMouseEnter = noop,
31
- onMouseLeave = noop,
32
31
  style,
33
32
  }: RowComponentProps<ResultData>): ReactElement => {
34
33
  const { resultWordWidth } = useAppLayout();
@@ -44,7 +43,6 @@ export const Result = ({
44
43
 
45
44
  const handleClick: MouseEventHandler = (event) => onClick(result, event);
46
45
  const handleMouseEnter: MouseEventHandler = (event) => onMouseEnter(result, event);
47
- const handleMouseLeave: MouseEventHandler = (event) => onMouseLeave(result, event);
48
46
  const handleBlur: FocusEventHandler = (event) => onBlur(result, event);
49
47
  const handleFocus: FocusEventHandler = (event) => onFocus(result, event);
50
48
 
@@ -65,7 +63,6 @@ export const Result = ({
65
63
  onClick={handleClick}
66
64
  onFocus={handleFocus}
67
65
  onMouseEnter={handleMouseEnter}
68
- onMouseLeave={handleMouseLeave}
69
66
  >
70
67
  <span className={styles.resultContent}>
71
68
  {columns[ResultColumnId.Coordinates] && (
@@ -93,7 +93,6 @@ $row-padding-horizontal: calc(var(--spacing--m) + var(--spacing--s));
93
93
  @include button-reset;
94
94
 
95
95
  user-select: none;
96
- transition: var(--transition);
97
96
  cursor: pointer;
98
97
  font-weight: 400;
99
98
 
@@ -44,7 +44,14 @@ export const Results: FunctionComponent<Props> = ({ callbacks, className, highli
44
44
  const isOutdated = useTypedSelector(selectAreResultsOutdated);
45
45
  const error = useTypedSelector(selectSolveError);
46
46
  const itemData = useMemo<ResultData>(
47
- () => ({ ...callbacks, highlightedIndex, results }),
47
+ () => ({
48
+ highlightedIndex,
49
+ results,
50
+ onBlur: callbacks.onBlur,
51
+ onClick: callbacks.onClick,
52
+ onFocus: callbacks.onFocus,
53
+ onMouseEnter: callbacks.onMouseEnter,
54
+ }),
48
55
  [callbacks, highlightedIndex, results],
49
56
  );
50
57
  const listRef = useListRef(null);
@@ -74,6 +81,7 @@ export const Results: FunctionComponent<Props> = ({ callbacks, className, highli
74
81
  aria-busy={isLoadingDebounced}
75
82
  aria-label={translate('results')}
76
83
  className={classNames(styles.results, className)}
84
+ data-outdated={isOutdated}
77
85
  data-testid="results"
78
86
  role="region"
79
87
  >
@@ -111,7 +119,7 @@ export const Results: FunctionComponent<Props> = ({ callbacks, className, highli
111
119
  )}
112
120
 
113
121
  {!isOutdated && results.length > 0 && (
114
- <div className={styles.listContainer}>
122
+ <div className={styles.listContainer} onMouseLeave={callbacks.onMouseLeave}>
115
123
  <List
116
124
  className={classNames(styles.list, {
117
125
  [styles.outdated]: isOutdated,
@@ -1,15 +1,15 @@
1
1
  import { type Result } from '@scrabble-solver/types';
2
- import { type FocusEvent, type MouseEvent } from 'react';
2
+ import { type FocusEvent, type MouseEvent, type MouseEventHandler } from 'react';
3
3
 
4
4
  export interface ResultCallbacks {
5
5
  onBlur?: (result: Result, event: FocusEvent) => void;
6
6
  onClick?: (result: Result, event: MouseEvent) => void;
7
7
  onFocus?: (result: Result, event: FocusEvent) => void;
8
8
  onMouseEnter?: (result: Result, event: MouseEvent) => void;
9
- onMouseLeave?: (result: Result, event: MouseEvent) => void;
9
+ onMouseLeave?: MouseEventHandler;
10
10
  }
11
11
 
12
- export interface ResultData extends ResultCallbacks {
12
+ export interface ResultData extends Omit<ResultCallbacks, 'onMouseLeave'> {
13
13
  highlightedIndex?: number;
14
14
  results: Result[] | undefined;
15
15
  }
@@ -14,6 +14,7 @@ import {
14
14
  solveSlice,
15
15
  useTranslate,
16
16
  useTypedSelector,
17
+ useTypedStore,
17
18
  } from '@/state';
18
19
 
19
20
  import { Alert } from '../Alert';
@@ -38,13 +39,13 @@ const SolverBase: FunctionComponent<Props> = ({ className, onShowResults }) => {
38
39
  const { maxControlsWidth, showCompactControls, tileSize } = useAppLayout();
39
40
  const error = useTypedSelector(selectSolveError);
40
41
  const isOutdated = useTypedSelector(selectAreResultsOutdated);
41
- const resultCandidate = useTypedSelector(selectResultCandidate);
42
42
  const results = useTypedSelector(selectProcessedResults);
43
+ const store = useTypedStore();
43
44
  const [bestResult] = results || [];
44
45
  const touchCallbacks = useMemo(
45
46
  () => ({
46
47
  onClick: (result: Result) => {
47
- const isSelected = result === resultCandidate;
48
+ const isSelected = result === selectResultCandidate(store.getState());
48
49
 
49
50
  if (isSelected) {
50
51
  dispatch(resultsSlice.actions.applyResult(result));
@@ -53,7 +54,7 @@ const SolverBase: FunctionComponent<Props> = ({ className, onShowResults }) => {
53
54
  }
54
55
  },
55
56
  }),
56
- [dispatch, resultCandidate],
57
+ [dispatch, store],
57
58
  );
58
59
  const mouseCallbacks = useMemo(
59
60
  () => ({
@@ -37,7 +37,7 @@ export const useTooltip = ({ placement = 'top' }: TooltipOptions = {}) => {
37
37
  const { delay } = useDelayGroup(context);
38
38
  const hover = useHover(context, { move: false, delay });
39
39
  const focus = useFocus(context);
40
- const dismiss = useDismiss(context);
40
+ const dismiss = useDismiss(context, { ancestorScroll: true });
41
41
  const role = useRole(context, { role: 'tooltip' });
42
42
  const interactions = useInteractions([hover, focus, dismiss, role]);
43
43
 
@@ -30,8 +30,8 @@ const dictionary = async (request: NextApiRequest, response: NextApiResponse): P
30
30
  },
31
31
  });
32
32
 
33
- const trie = await dictionaries.get(locale);
34
- const results = await Promise.all(words.map((word) => getWordDefinition(locale, word, trie.has(word))));
33
+ const gaddag = await dictionaries.get(locale);
34
+ const results = await Promise.all(words.map((word) => getWordDefinition(locale, word, gaddag.has(word))));
35
35
  response.status(200).send(results.map((result) => result.toJson()));
36
36
  } catch (error) {
37
37
  const message = error instanceof Error ? error.message : 'Unknown error';
@@ -1,7 +1,10 @@
1
+ import { type Gaddag } from '@kamilmielnik/gaddag';
1
2
  import { dictionaries } from '@scrabble-solver/dictionaries';
2
3
  import { logger } from '@scrabble-solver/logger';
3
4
  import { isLocale, type Locale } from '@scrabble-solver/types';
4
5
  import { type NextApiRequest, type NextApiResponse } from 'next';
6
+ import { promisify } from 'util';
7
+ import { gzip } from 'zlib';
5
8
 
6
9
  import { getServerLoggingData } from '@/api';
7
10
 
@@ -22,8 +25,21 @@ const dictionary = async (request: NextApiRequest, response: NextApiResponse): P
22
25
  },
23
26
  });
24
27
 
25
- const trie = await dictionaries.get(locale);
26
- response.status(200).send(trie.serialize());
28
+ const gaddag = await dictionaries.get(locale);
29
+ const isGzip = acceptsGzip(request);
30
+ const body = isGzip ? await getCompressedDictionary(gaddag) : getSerializedDictionary(gaddag);
31
+
32
+ // no-cache makes clients store the payload but revalidate it, so unchanged
33
+ // re-downloads become 304s, answered by Next's built-in ETag handling.
34
+ response.setHeader('Cache-Control', 'no-cache');
35
+ response.setHeader('Content-Type', 'application/octet-stream');
36
+ response.setHeader('Vary', 'Accept-Encoding');
37
+
38
+ if (isGzip) {
39
+ response.setHeader('Content-Encoding', 'gzip');
40
+ }
41
+
42
+ response.status(200).send(body);
27
43
  } catch (error) {
28
44
  const message = error instanceof Error ? error.message : 'Unknown error';
29
45
  logger.error('dictionary - error', { error, meta });
@@ -43,9 +59,70 @@ const parseRequest = (request: NextApiRequest): RequestData => {
43
59
  };
44
60
  };
45
61
 
62
+ const acceptsGzip = (request: NextApiRequest): boolean => {
63
+ const acceptEncoding = request.headers['accept-encoding'];
64
+ const header = Array.isArray(acceptEncoding) ? acceptEncoding.join(',') : acceptEncoding;
65
+
66
+ if (typeof header !== 'string') {
67
+ return false;
68
+ }
69
+
70
+ let wildcardAccepts = false;
71
+
72
+ for (const entry of header.split(',')) {
73
+ const [encoding, ...parameters] = entry.split(';').map((token) => token.trim().toLowerCase());
74
+ const isRefused = parameters.some((parameter) => {
75
+ const [name, quality] = parameter.split('=').map((token) => token.trim());
76
+ return name === 'q' && parseFloat(quality) === 0;
77
+ });
78
+
79
+ if (encoding === 'gzip' || encoding === 'x-gzip') {
80
+ return !isRefused;
81
+ }
82
+
83
+ if (encoding === '*') {
84
+ wildcardAccepts = !isRefused;
85
+ }
86
+ }
87
+
88
+ return wildcardAccepts;
89
+ };
90
+
91
+ // Dictionaries rebuild at most daily, so each response variant is produced
92
+ // once and lives exactly as long as the in-memory dictionary it was made from.
93
+ const serializedCache = new WeakMap<Gaddag, Buffer>();
94
+ const compressedCache = new WeakMap<Gaddag, Promise<Buffer>>();
95
+
96
+ const getSerializedDictionary = (gaddag: Gaddag): Buffer => {
97
+ let serialized = serializedCache.get(gaddag);
98
+
99
+ if (!serialized) {
100
+ // serialize() returns freshly allocated bytes, so the Buffer wraps them without copying.
101
+ const bytes = gaddag.serialize();
102
+ serialized = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
103
+ serializedCache.set(gaddag, serialized);
104
+ }
105
+
106
+ return serialized;
107
+ };
108
+
109
+ const gzipAsync = promisify(gzip);
110
+
111
+ const getCompressedDictionary = (gaddag: Gaddag): Promise<Buffer> => {
112
+ let compressed = compressedCache.get(gaddag);
113
+
114
+ if (!compressed) {
115
+ compressed = gzipAsync(gaddag.serialize());
116
+ compressedCache.set(gaddag, compressed);
117
+ compressed.catch(() => compressedCache.delete(gaddag));
118
+ }
119
+
120
+ return compressed;
121
+ };
122
+
46
123
  export const config = {
47
124
  api: {
48
- responseLimit: '25mb',
125
+ responseLimit: '50mb',
49
126
  },
50
127
  };
51
128
 
@@ -45,9 +45,9 @@ const solve = async (request: NextApiRequest, response: NextApiResponse): Promis
45
45
  },
46
46
  });
47
47
 
48
- const trie = await dictionaries.get(locale);
48
+ const gaddag = await dictionaries.get(locale);
49
49
  const tiles = characters.map((character) => new Tile({ character, isBlank: character === BLANK }));
50
- const results = solveScrabble(trie, config, board, tiles);
50
+ const results = solveScrabble(gaddag, config, board, tiles);
51
51
  response.status(200).send(results);
52
52
  } catch (error) {
53
53
  const message = error instanceof Error ? error.message : 'Unknown error';
@@ -30,10 +30,19 @@ const verify = async (request: NextApiRequest, response: NextApiResponse): Promi
30
30
  },
31
31
  });
32
32
 
33
- const trie = await dictionaries.get(locale);
33
+ const gaddag = await dictionaries.get(locale);
34
34
  const words = board.getWords().sort((a, b) => a.localeCompare(b, locale));
35
- const invalidWords = words.filter((word) => !trie.has(word));
36
- const validWords = words.filter((word) => trie.has(word));
35
+ const invalidWords: string[] = [];
36
+ const validWords: string[] = [];
37
+
38
+ for (const word of words) {
39
+ if (gaddag.has(word)) {
40
+ validWords.push(word);
41
+ } else {
42
+ invalidWords.push(word);
43
+ }
44
+ }
45
+
37
46
  response.status(200).send({ invalidWords, validWords });
38
47
  } catch (error) {
39
48
  const message = error instanceof Error ? error.message : 'Unknown error';
package/src/sdk/solve.ts CHANGED
@@ -1,19 +1,25 @@
1
- import { type BoardJson, type Locale, Result, type ResultJson } from '@scrabble-solver/types';
1
+ import { Board, type BoardJson, type Game, type Locale, Result, type ResultJson } from '@scrabble-solver/types';
2
+
3
+ import { solveLocally } from '@/solver-worker';
2
4
 
3
5
  import { fetchJson } from './fetchJson';
4
6
 
5
7
  interface Payload {
6
8
  board: BoardJson;
7
9
  characters: string[];
8
- game: string;
10
+ game: Game;
9
11
  locale: Locale;
10
12
  }
11
13
 
12
14
  export const solve = async ({ board, characters, game, locale }: Payload): Promise<Result[]> => {
13
- const json = await fetchJson<ResultJson[]>('/api/solve', {
14
- method: 'POST',
15
- body: JSON.stringify({ board, characters, game, locale }),
16
- });
15
+ const payload = { board, characters, game, locale };
16
+ const json =
17
+ (await solveLocally(payload)) ??
18
+ (await fetchJson<ResultJson[]>('/api/solve', {
19
+ method: 'POST',
20
+ body: JSON.stringify(payload),
21
+ }));
17
22
 
18
- return json.map(Result.fromJson);
23
+ const decodedBoard = Board.fromJson(board);
24
+ return json.map((result) => Result.fromJson(result, decodedBoard));
19
25
  };
package/src/sdk/verify.ts CHANGED
@@ -1,10 +1,12 @@
1
- import { type BoardJson, type Locale } from '@scrabble-solver/types';
1
+ import { type BoardJson, type Game, type Locale } from '@scrabble-solver/types';
2
+
3
+ import { verifyLocally } from '@/solver-worker';
2
4
 
3
5
  import { fetchJson } from './fetchJson';
4
6
 
5
7
  interface Payload {
6
8
  board: BoardJson;
7
- game: string;
9
+ game: Game;
8
10
  locale: Locale;
9
11
  }
10
12
 
@@ -14,8 +16,12 @@ interface Response {
14
16
  }
15
17
 
16
18
  export const verify = async ({ board, game, locale }: Payload): Promise<Response> => {
17
- return fetchJson<Response>('/api/verify', {
18
- method: 'POST',
19
- body: JSON.stringify({ board, game, locale }),
20
- });
19
+ const payload = { board, game, locale };
20
+ return (
21
+ (await verifyLocally(payload)) ??
22
+ fetchJson<Response>('/api/verify', {
23
+ method: 'POST',
24
+ body: JSON.stringify(payload),
25
+ })
26
+ );
21
27
  };