@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
@@ -0,0 +1,86 @@
1
+ import { type Locale } from '@scrabble-solver/types';
2
+
3
+ import { DICTIONARY_CACHE } from './constants';
4
+ import { expirationManager } from './expirationManager';
5
+ import { getDictionaryUrl } from './getDictionaryUrl';
6
+ import { clearRevalidatedAt, readRevalidatedAt, writeRevalidatedAt } from './revalidatedAt';
7
+
8
+ const REVALIDATION_INTERVAL = 60 * 60 * 1000;
9
+
10
+ const requests: Partial<Record<Locale, Promise<void> | undefined>> = {};
11
+ const resetWhileRevalidating = new Set<Locale>();
12
+
13
+ export function revalidateDictionary(locale: Locale): Promise<void> {
14
+ const inFlight = requests[locale];
15
+
16
+ if (inFlight) {
17
+ return inFlight;
18
+ }
19
+
20
+ // Registered before the first await, so concurrent callers share this request.
21
+ const request = revalidateWhenStale(locale).finally(() => {
22
+ requests[locale] = undefined;
23
+ });
24
+
25
+ requests[locale] = request;
26
+ return request;
27
+ }
28
+
29
+ /**
30
+ * Lets the next access revalidate immediately - used when the cached dictionary
31
+ * is deleted, so its replacement is not stuck behind the throttle.
32
+ * A revalidation already in flight when this runs saw the deleted entry, so it
33
+ * must not arm the throttle when it finishes.
34
+ * Both in-memory guards are raised before the first await, so a caller that
35
+ * runs this straight after deleting the entry leaves no gap for a revalidation
36
+ * to slip into.
37
+ */
38
+ export async function resetRevalidationThrottle(locale: Locale): Promise<void> {
39
+ resetWhileRevalidating.add(locale);
40
+ await clearRevalidatedAt(locale);
41
+ }
42
+
43
+ async function revalidateWhenStale(locale: Locale): Promise<void> {
44
+ if (Date.now() - (await readRevalidatedAt(locale)) < REVALIDATION_INTERVAL) {
45
+ return;
46
+ }
47
+
48
+ resetWhileRevalidating.delete(locale);
49
+ await revalidate(locale);
50
+
51
+ if (!resetWhileRevalidating.has(locale)) {
52
+ await writeRevalidatedAt(locale, Date.now());
53
+ }
54
+ }
55
+
56
+ /**
57
+ * `no-cache` revalidates against the server without letting the HTTP cache
58
+ * answer, while still allowing a 304 that keeps the multi-megabyte cached body
59
+ * untouched; only a real 200 rewrites it. `no-store` would look equivalent but
60
+ * makes the browser send `Cache-Control: no-cache`, which Next.js reads as an
61
+ * unconditional request and answers with the whole body every time.
62
+ */
63
+ async function revalidate(locale: Locale): Promise<void> {
64
+ await expirationManager.expireEntries();
65
+
66
+ const url = getDictionaryUrl(locale);
67
+ const cache = await caches.open(DICTIONARY_CACHE);
68
+ const cached = await cache.match(url);
69
+ const etag = cached?.headers.get('etag');
70
+ const response = await fetch(url, {
71
+ cache: 'no-cache',
72
+ headers: etag ? { 'If-None-Match': etag } : {},
73
+ });
74
+
75
+ if (response.status === 304) {
76
+ await expirationManager.updateTimestamp(url);
77
+ return;
78
+ }
79
+
80
+ if (!response.ok) {
81
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
82
+ }
83
+
84
+ await cache.put(url, response);
85
+ await expirationManager.updateTimestamp(url);
86
+ }
@@ -0,0 +1,43 @@
1
+ import { type Locale } from '@scrabble-solver/types';
2
+
3
+ import { REVALIDATED_AT_CACHE } from './constants';
4
+ import { getDictionaryUrl } from './getDictionaryUrl';
5
+
6
+ const timestamps: Partial<Record<Locale, number>> = {};
7
+
8
+ /**
9
+ * A dedicated worker is created per page, so a timestamp kept only in memory
10
+ * would let every navigation revalidate again. The Cache API is the only
11
+ * storage a worker shares with other tabs, so the throttle lives there and is
12
+ * read at most once per locale per worker.
13
+ */
14
+ export async function readRevalidatedAt(locale: Locale): Promise<number> {
15
+ const known = timestamps[locale];
16
+
17
+ if (typeof known !== 'undefined') {
18
+ return known;
19
+ }
20
+
21
+ const persisted = await readPersistedRevalidatedAt(locale);
22
+ // A reset that landed while reading wins over the value it invalidated.
23
+ return (timestamps[locale] ??= persisted);
24
+ }
25
+
26
+ export async function writeRevalidatedAt(locale: Locale, timestamp: number): Promise<void> {
27
+ timestamps[locale] = timestamp;
28
+ const cache = await caches.open(REVALIDATED_AT_CACHE);
29
+ await cache.put(getDictionaryUrl(locale), new Response(String(timestamp)));
30
+ }
31
+
32
+ export async function clearRevalidatedAt(locale: Locale): Promise<void> {
33
+ timestamps[locale] = 0;
34
+ const cache = await caches.open(REVALIDATED_AT_CACHE);
35
+ await cache.delete(getDictionaryUrl(locale));
36
+ }
37
+
38
+ async function readPersistedRevalidatedAt(locale: Locale): Promise<number> {
39
+ const cache = await caches.open(REVALIDATED_AT_CACHE);
40
+ const response = await cache.match(getDictionaryUrl(locale));
41
+ const timestamp = Number(await response?.text());
42
+ return Number.isFinite(timestamp) ? timestamp : 0;
43
+ }
@@ -0,0 +1,109 @@
1
+ import { Gaddag } from '@kamilmielnik/gaddag';
2
+ import { Locale } from '@scrabble-solver/types';
3
+ import { describe, expect, it, mock } from 'bun:test';
4
+
5
+ import type * as getGaddagModule from './getGaddag';
6
+
7
+ let dictionary: Response | undefined;
8
+ const deletedLocales: Locale[] = [];
9
+
10
+ await mock.module('./dictionaries', () => ({
11
+ deleteDictionary: (locale: Locale) => {
12
+ deletedLocales.push(locale);
13
+ return Promise.resolve();
14
+ },
15
+ getDictionary: () => Promise.resolve(dictionary),
16
+ }));
17
+
18
+ const { getGaddag }: typeof getGaddagModule = await import('./getGaddag');
19
+
20
+ describe('getGaddag', () => {
21
+ it('returns undefined when there is no cached dictionary', async () => {
22
+ dictionary = undefined;
23
+ expect(await getGaddag(Locale.EN_US)).toBeUndefined();
24
+ });
25
+
26
+ it('deserializes a cached dictionary', async () => {
27
+ dictionary = createDictionary(['scrabble', 'solver']);
28
+ const gaddag = await getGaddag(Locale.EN_US);
29
+
30
+ expect(gaddag?.has('scrabble')).toBe(true);
31
+ expect(gaddag?.has('trie')).toBe(false);
32
+ });
33
+
34
+ it('deletes a dictionary cached by an app version with an incompatible format', async () => {
35
+ dictionary = new Response('serialized trie from a previous app version');
36
+
37
+ expect(await getGaddag(Locale.PL_PL)).toBeUndefined();
38
+ expect(deletedLocales).toContain(Locale.PL_PL);
39
+ });
40
+
41
+ it('deletes a corrupted dictionary', async () => {
42
+ dictionary = new Response(Gaddag.fromArray(['scrabble']).serialize().subarray(0, 10));
43
+
44
+ expect(await getGaddag(Locale.FA_IR)).toBeUndefined();
45
+ expect(deletedLocales).toContain(Locale.FA_IR);
46
+ });
47
+
48
+ it('reuses the deserialized dictionary while the cached response is unchanged', async () => {
49
+ dictionary = createDictionary(['scrabble'], { etag: '"v1"' });
50
+ const first = await getGaddag(Locale.EN_GB);
51
+ dictionary = createDictionary(['scrabble'], { etag: '"v1"' });
52
+ const second = await getGaddag(Locale.EN_GB);
53
+
54
+ expect(first?.has('scrabble')).toBe(true);
55
+ expect(second).toBe(first);
56
+ });
57
+
58
+ it('reuses the deserialized dictionary when a revalidation only refreshes the Date header', async () => {
59
+ dictionary = createDictionary(['scrabble'], { date: 'Thu, 06 Aug 2026 00:00:00 GMT', etag: '"v1"' });
60
+ const first = await getGaddag(Locale.FR_FR);
61
+ dictionary = createDictionary(['scrabble'], { date: 'Fri, 07 Aug 2026 00:00:00 GMT', etag: '"v1"' });
62
+ const second = await getGaddag(Locale.FR_FR);
63
+
64
+ expect(first?.has('scrabble')).toBe(true);
65
+ expect(second).toBe(first);
66
+ });
67
+
68
+ it('picks up a dictionary another tab wrote into the shared cache', async () => {
69
+ dictionary = createDictionary(['scrabble'], { etag: '"v1"' });
70
+ const first = await getGaddag(Locale.TR_TR);
71
+ dictionary = createDictionary(['solver'], { etag: '"v2"' });
72
+ const second = await getGaddag(Locale.TR_TR);
73
+
74
+ expect(first?.has('scrabble')).toBe(true);
75
+ expect(second).not.toBe(first);
76
+ expect(second?.has('solver')).toBe(true);
77
+ });
78
+
79
+ it('falls back to the Date header for responses without an ETag', async () => {
80
+ dictionary = createDictionary(['scrabble'], { date: 'Thu, 06 Aug 2026 00:00:00 GMT' });
81
+ const first = await getGaddag(Locale.DE_DE);
82
+ dictionary = createDictionary(['scrabble'], { date: 'Thu, 06 Aug 2026 00:00:00 GMT' });
83
+ const second = await getGaddag(Locale.DE_DE);
84
+ dictionary = createDictionary(['scrabble'], { date: 'Fri, 07 Aug 2026 00:00:00 GMT' });
85
+ const third = await getGaddag(Locale.DE_DE);
86
+
87
+ expect(first?.has('scrabble')).toBe(true);
88
+ expect(second).toBe(first);
89
+ expect(third).not.toBe(first);
90
+ expect(third?.has('scrabble')).toBe(true);
91
+ });
92
+
93
+ it('memoizes each locale independently', async () => {
94
+ dictionary = createDictionary(['scrabble'], { etag: '"es"' });
95
+ const spanish = await getGaddag(Locale.ES_ES);
96
+ dictionary = createDictionary(['solver'], { etag: '"ro"' });
97
+ const romanian = await getGaddag(Locale.RO_RO);
98
+ dictionary = createDictionary(['scrabble'], { etag: '"es"' });
99
+ const spanishAgain = await getGaddag(Locale.ES_ES);
100
+
101
+ expect(spanish?.has('scrabble')).toBe(true);
102
+ expect(romanian?.has('solver')).toBe(true);
103
+ expect(spanishAgain).toBe(spanish);
104
+ });
105
+ });
106
+
107
+ function createDictionary(words: string[], headers: Record<string, string> = {}): Response {
108
+ return new Response(Gaddag.fromArray(words).serialize(), { headers });
109
+ }
@@ -0,0 +1,62 @@
1
+ import { Gaddag } from '@kamilmielnik/gaddag';
2
+ import { type Locale } from '@scrabble-solver/types';
3
+
4
+ import { deleteDictionary, getDictionary } from './dictionaries';
5
+
6
+ interface DeserializedDictionary {
7
+ gaddag: Gaddag;
8
+ version: string;
9
+ }
10
+
11
+ const deserializedDictionaries: Partial<Record<Locale, DeserializedDictionary>> = {};
12
+
13
+ /**
14
+ * Returns undefined when there is no cached dictionary or when the cached
15
+ * dictionary cannot be deserialized (it was downloaded by an app version with
16
+ * an incompatible format). An undeserializable entry is deleted before
17
+ * returning, so the revalidation the caller triggers next downloads a fresh
18
+ * dictionary instead of getting a 304 for the broken bytes.
19
+ */
20
+ export async function getGaddag(locale: Locale): Promise<Gaddag | undefined> {
21
+ const response = await getDictionary(locale);
22
+
23
+ if (typeof response === 'undefined') {
24
+ return undefined;
25
+ }
26
+
27
+ const version = readVersion(response);
28
+ const deserialized = deserializedDictionaries[locale];
29
+
30
+ if (deserialized && deserialized.version === version) {
31
+ return deserialized.gaddag;
32
+ }
33
+
34
+ try {
35
+ const gaddag = Gaddag.deserialize(new Uint8Array(await response.arrayBuffer()));
36
+
37
+ if (typeof version !== 'undefined') {
38
+ deserializedDictionaries[locale] = { gaddag, version };
39
+ }
40
+
41
+ return gaddag;
42
+ } catch {
43
+ delete deserializedDictionaries[locale];
44
+ await deleteDictionary(locale);
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Read from the cached response rather than tracked in memory, so a dictionary
51
+ * another tab downloaded into the shared cache is picked up here too.
52
+ */
53
+ function readVersion(response: Response): string | undefined {
54
+ const etag = response.headers.get('etag');
55
+
56
+ if (etag !== null) {
57
+ return etag;
58
+ }
59
+
60
+ const parts = ['date', 'content-length'].map((name) => response.headers.get(name) ?? '');
61
+ return parts.every((part) => part === '') ? undefined : parts.join('|');
62
+ }
@@ -0,0 +1 @@
1
+ export { prefetchDictionary, solveLocally, verifyLocally } from './client';
@@ -0,0 +1,124 @@
1
+ /// <reference lib="WebWorker" />
2
+
3
+ import { getConfig } from '@scrabble-solver/configs';
4
+ import { BLANK } from '@scrabble-solver/constants';
5
+ import { solve } from '@scrabble-solver/solver';
6
+ import { Board, type Locale, Tile } from '@scrabble-solver/types';
7
+
8
+ import { type SolveRequestPayload, type VerifyRequestPayload } from '@/types';
9
+
10
+ import { revalidateDictionary } from './dictionaries';
11
+ import { getGaddag } from './getGaddag';
12
+ import { type SolverWorkerRequest, type SolverWorkerResponse } from './messages';
13
+
14
+ declare const self: DedicatedWorkerGlobalScope;
15
+
16
+ /**
17
+ * Solves run synchronously on the single worker thread, so a burst of requests
18
+ * would queue stale solves ahead of the latest one.
19
+ */
20
+ let latestSolveId = 0;
21
+
22
+ self.addEventListener('message', ({ data }: MessageEvent<SolverWorkerRequest>) => {
23
+ if (data.type === 'solve') {
24
+ latestSolveId = data.id;
25
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
26
+ respond(data.id, () => handleSolve(data.id, data.payload));
27
+ } else if (data.type === 'verify') {
28
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
29
+ respond(data.id, () => handleVerify(data.id, data.payload));
30
+ } else if (data.type === 'prefetch') {
31
+ prefetchDictionary(data.locale).catch(() => undefined);
32
+ }
33
+ });
34
+
35
+ async function handleSolve(id: number, payload: SolveRequestPayload): Promise<SolverWorkerResponse> {
36
+ const { board, characters, game, locale } = payload;
37
+ const gaddag = await getGaddag(locale);
38
+ revalidateInBackground(locale);
39
+
40
+ if (!gaddag) {
41
+ return { id, outcome: 'unavailable' };
42
+ }
43
+
44
+ await yieldToQueuedMessages();
45
+
46
+ if (latestSolveId !== id) {
47
+ return { id, outcome: 'superseded' };
48
+ }
49
+
50
+ const config = getConfig(game, locale);
51
+ const tiles = characters.map((character) => new Tile({ character, isBlank: character === BLANK }));
52
+ return { data: solve(gaddag, config, Board.fromJson(board), tiles), id, outcome: 'answered' };
53
+ }
54
+
55
+ async function handleVerify(
56
+ id: number,
57
+ { board: boardJson, locale }: VerifyRequestPayload,
58
+ ): Promise<SolverWorkerResponse> {
59
+ const gaddag = await getGaddag(locale);
60
+ revalidateInBackground(locale);
61
+
62
+ if (!gaddag) {
63
+ return { id, outcome: 'unavailable' };
64
+ }
65
+
66
+ const board = Board.fromJson(boardJson);
67
+ const words = board.getWords().sort((a, b) => a.localeCompare(b, locale));
68
+ const invalidWords: string[] = [];
69
+ const validWords: string[] = [];
70
+
71
+ for (const word of words) {
72
+ if (gaddag.has(word)) {
73
+ validWords.push(word);
74
+ } else {
75
+ invalidWords.push(word);
76
+ }
77
+ }
78
+
79
+ return { data: { invalidWords, validWords }, id, outcome: 'answered' };
80
+ }
81
+
82
+ /**
83
+ * Deserializes the dictionary ahead of the first solve, so it does not pay the
84
+ * cold-start cost. Warms even when revalidation fails (e.g. offline) - a
85
+ * previously cached dictionary can still be deserialized.
86
+ */
87
+ async function prefetchDictionary(locale: Locale): Promise<void> {
88
+ try {
89
+ await revalidateDictionary(locale);
90
+ } catch {
91
+ // Solving falls back to the server until a later revalidation succeeds.
92
+ }
93
+
94
+ await getGaddag(locale);
95
+ }
96
+
97
+ /**
98
+ * Revalidation failures are expected (offline, server down) and must not
99
+ * surface as unhandled rejections - solving keeps using the cached dictionary
100
+ * until a later revalidation succeeds.
101
+ */
102
+ function revalidateInBackground(locale: Locale): void {
103
+ revalidateDictionary(locale).catch(() => undefined);
104
+ }
105
+
106
+ async function respond(id: number, handle: () => Promise<SolverWorkerResponse>): Promise<void> {
107
+ try {
108
+ self.postMessage(await handle());
109
+ } catch {
110
+ self.postMessage({ id, outcome: 'unavailable' } satisfies SolverWorkerResponse);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Messages queued behind this handler dispatch only between macrotasks - a
116
+ * MessageChannel round-trip lets them register before an expensive solve.
117
+ */
118
+ function yieldToQueuedMessages(): Promise<void> {
119
+ return new Promise((resolve) => {
120
+ const { port1, port2 } = new MessageChannel();
121
+ port1.onmessage = () => resolve();
122
+ port2.postMessage(null);
123
+ });
124
+ }
@@ -0,0 +1,20 @@
1
+ import { type Locale, type ResultJson } from '@scrabble-solver/types';
2
+
3
+ import { type SolveRequestPayload, type VerifyRequestPayload } from '@/types';
4
+
5
+ export interface VerifyResult {
6
+ invalidWords: string[];
7
+ validWords: string[];
8
+ }
9
+
10
+ export type SolverWorkerRequest =
11
+ | { id: number; type: 'prefetch'; locale: Locale }
12
+ | { id: number; type: 'solve'; payload: SolveRequestPayload }
13
+ | { id: number; type: 'verify'; payload: VerifyRequestPayload };
14
+
15
+ export type SolverWorkerResponse =
16
+ | { id: number; outcome: 'answered'; data: ResultJson[] | VerifyResult }
17
+ /** No cached dictionary, or an internal error - the caller falls back to the server. */
18
+ | { id: number; outcome: 'unavailable' }
19
+ /** A newer solve replaced this one - the newer answer is the one worth waiting for. */
20
+ | { id: number; outcome: 'superseded' };
@@ -12,4 +12,5 @@ export * from './solve';
12
12
  export * from './store';
13
13
  export * from './useTranslate';
14
14
  export * from './useTypedSelector';
15
+ export * from './useTypedStore';
15
16
  export * from './verify';
@@ -6,11 +6,12 @@
6
6
  import { type PayloadAction } from '@reduxjs/toolkit';
7
7
  import { hasConfig, languages } from '@scrabble-solver/configs';
8
8
  import { Board, type Locale, type Result } from '@scrabble-solver/types';
9
- import { call, delay, put, select, takeEvery, takeLatest } from 'redux-saga/effects';
9
+ import { call, delay, put, select, spawn, takeEvery, takeLatest } from 'redux-saga/effects';
10
10
 
11
11
  import { LOCALE_FEATURES } from '@/i18n';
12
12
  import { memoize } from '@/lib';
13
13
  import { findWordDefinitions, solve, verify, visit } from '@/sdk';
14
+ import { prefetchDictionary } from '@/solver-worker';
14
15
 
15
16
  import { initialize, reset } from './actions';
16
17
  import { boardSlice, selectBoard } from './board';
@@ -41,7 +42,7 @@ export function* rootSaga(): AnyGenerator {
41
42
  yield takeEvery(boardSlice.actions.changeCellValue.type, onCellValueChange);
42
43
  yield takeEvery([rackSlice.actions.changeCharacter.type, rackSlice.actions.changeCharacters.type], onRackValueChange);
43
44
  yield takeEvery(resultsSlice.actions.applyResult.type, onApplyResult);
44
- yield takeEvery(resultsSlice.actions.changeResultCandidate.type, onResultCandidateChange);
45
+ yield takeLatest(resultsSlice.actions.changeResultCandidate.type, onResultCandidateChange);
45
46
  yield takeEvery(settingsSlice.actions.changeGame.type, onGameChange);
46
47
  yield takeEvery(settingsSlice.actions.changeLocale.type, onLocaleChange);
47
48
  yield takeLatest(dictionarySlice.actions.submit.type, onDictionarySubmit);
@@ -120,8 +121,11 @@ function* onDictionarySubmit(): AnyGenerator {
120
121
 
121
122
  function* onInitialize(): AnyGenerator {
122
123
  const board = yield select(selectBoard);
124
+ const locale = yield select(selectLocale);
123
125
 
124
- yield call(visit);
126
+ yield spawn(prefetchDictionary, locale);
127
+ // Detached: offline, a failed visit would otherwise tear down the whole root saga.
128
+ yield spawn(visit);
125
129
 
126
130
  if (!board.isEmpty()) {
127
131
  yield* resetRack();
@@ -142,6 +146,8 @@ function* onReset(): AnyGenerator {
142
146
  }
143
147
 
144
148
  function* onLocaleChange({ payload: locale }: PayloadAction<Locale>): AnyGenerator {
149
+ yield spawn(prefetchDictionary, locale);
150
+
145
151
  const game = yield select(selectGame);
146
152
 
147
153
  if (!hasConfig(game, locale)) {
@@ -166,13 +172,17 @@ function* onLocaleChange({ payload: locale }: PayloadAction<Locale>): AnyGenerat
166
172
  }
167
173
 
168
174
  function* onResultCandidateChange({ payload: result }: PayloadAction<Result | null>): AnyGenerator {
169
- if (result) {
170
- const locale: Locale = yield select(selectLocale);
171
- const uniqueWords = Array.from(new Set(result.words));
172
- const input = uniqueWords.join(LOCALE_FEATURES[locale].separator);
173
- yield put(dictionarySlice.actions.changeInput(input));
174
- yield put(dictionarySlice.actions.submit());
175
+ if (!result) {
176
+ return;
175
177
  }
178
+
179
+ yield delay(SUBMIT_DELAY);
180
+
181
+ const locale: Locale = yield select(selectLocale);
182
+ const uniqueWords = Array.from(new Set(result.words));
183
+ const input = uniqueWords.join(LOCALE_FEATURES[locale].separator);
184
+ yield put(dictionarySlice.actions.changeInput(input));
185
+ yield put(dictionarySlice.actions.submit());
176
186
  }
177
187
 
178
188
  function* onSolve(): AnyGenerator {
@@ -0,0 +1,6 @@
1
+ import { type Store } from '@reduxjs/toolkit';
2
+ import { useStore } from 'react-redux';
3
+
4
+ import type { RootState } from './types';
5
+
6
+ export const useTypedStore = (): Store<RootState> => useStore<RootState>();
package/src/types/api.ts CHANGED
@@ -1,9 +1,8 @@
1
- import { type BoardJson, type ConfigJson, type Game, type Locale } from '@scrabble-solver/types';
1
+ import { type BoardJson, type Game, type Locale } from '@scrabble-solver/types';
2
2
 
3
3
  export interface SolveRequestPayload {
4
4
  board: BoardJson;
5
5
  characters: string[];
6
- config: ConfigJson;
7
6
  game: Game;
8
7
  locale: Locale;
9
8
  }
package/tsconfig.json CHANGED
@@ -7,6 +7,10 @@
7
7
  "isolatedModules": true,
8
8
  "jsx": "preserve",
9
9
  "lib": ["dom", "dom.iterable", "esnext"],
10
+ // Webpack bundles this package (import.meta worker URLs need an ES module
11
+ // target), unlike the nodenext library packages.
12
+ "module": "esnext",
13
+ "moduleResolution": "bundler",
10
14
  "noEmit": true,
11
15
  "paths": {
12
16
  "@/*": ["./src/*"]