@ethlete/query 6.0.0-next.52 → 6.0.0-next.53

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.
@@ -5,6 +5,8 @@ export default async function migrate(tree, schema) {
5
5
  console.log('\nšŸ”„ Starting Query v3 symbol conflict migration...');
6
6
  renameConflictingSymbols(tree);
7
7
  replaceExperimentalQueryNamespace(tree);
8
+ reportWithArgsNullReturns(tree);
9
+ replaceClearQueryArgs(tree);
8
10
  reportPrebuiltPackagesImportingRenamedSymbols(tree);
9
11
  if (!schema.skipFormat) {
10
12
  await formatFiles(tree);
@@ -75,10 +77,19 @@ function renameConflictingSymbols(tree) {
75
77
  }
76
78
  function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
77
79
  const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
80
+ const checker = createBindingChecker(sourceFile);
78
81
  const replacements = [];
79
- // Track which symbols are imported from @ethlete/query
80
- const importedSymbols = new Set();
81
82
  const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
83
+ const renamedImports = new Map();
84
+ sourceFile.statements.forEach((statement) => {
85
+ getQueryNamedImports(statement)?.elements.forEach((element) => {
86
+ const newName = TYPE_RENAMES.get(element.name.text) || FUNCTION_RENAMES.get(element.name.text);
87
+ const symbol = checker.getSymbolAtLocation(element.name);
88
+ if (!element.propertyName && newName && symbol) {
89
+ renamedImports.set(symbol, { oldName: element.name.text, newName });
90
+ }
91
+ });
92
+ });
82
93
  function visit(node) {
83
94
  const namespaceMember = getNamespaceMember(node, namespaceAliases)?.member;
84
95
  const renamedMember = namespaceMember && (TYPE_RENAMES.get(namespaceMember.text) || FUNCTION_RENAMES.get(namespaceMember.text));
@@ -90,62 +101,29 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
90
101
  oldName: namespaceMember.text,
91
102
  });
92
103
  }
93
- // Track imports from @ethlete/query
94
- if (ts.isImportDeclaration(node)) {
95
- const moduleSpecifier = node.moduleSpecifier;
96
- if (ts.isStringLiteral(moduleSpecifier) && moduleSpecifier.text === '@ethlete/query') {
97
- const namedBindings = node.importClause?.namedBindings;
98
- if (namedBindings && ts.isNamedImports(namedBindings)) {
99
- namedBindings.elements.forEach((element) => {
100
- const importedName = element.propertyName?.text || element.name.text;
101
- if (TYPE_RENAMES.has(importedName) || FUNCTION_RENAMES.has(importedName)) {
102
- importedSymbols.add(importedName);
103
- }
104
- });
105
- }
106
- }
107
- }
108
- // Rename identifiers that match our rename maps
109
- if (ts.isIdentifier(node)) {
110
- const name = node.text;
111
- const parent = node.parent;
112
- // Only rename if this symbol was imported from @ethlete/query
113
- if (!importedSymbols.has(name)) {
114
- ts.forEachChild(node, visit);
115
- return;
116
- }
117
- const newName = TYPE_RENAMES.get(name) || FUNCTION_RENAMES.get(name);
118
- if (!newName) {
119
- ts.forEachChild(node, visit);
120
- return;
104
+ if (ts.isIdentifier(node) && !ts.isImportSpecifier(node.parent)) {
105
+ const symbol = resolveReferenceSymbol(checker, node);
106
+ const rename = symbol && renamedImports.get(symbol);
107
+ if (rename) {
108
+ replacements.push({
109
+ start: node.getStart(sourceFile),
110
+ end: node.getEnd(),
111
+ replacement: ts.isShorthandPropertyAssignment(node.parent)
112
+ ? `${rename.oldName}: ${rename.newName}`
113
+ : rename.newName,
114
+ oldName: rename.oldName,
115
+ });
121
116
  }
122
- // Skip if it's the imported name in an import specifier (we'll handle that separately)
123
- if ((ts.isImportSpecifier(parent) && (parent.propertyName === node || parent.name === node)) ||
124
- (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
125
- (ts.isQualifiedName(parent) && parent.right === node)) {
126
- ts.forEachChild(node, visit);
127
- return;
128
- }
129
- // Rename the identifier
130
- replacements.push({
131
- start: node.getStart(sourceFile),
132
- end: node.getEnd(),
133
- replacement: newName,
134
- oldName: name,
135
- });
136
117
  }
137
118
  ts.forEachChild(node, visit);
138
119
  }
139
120
  visit(sourceFile);
140
- // Update imports in @ethlete/query import declarations
141
121
  const importReplacements = updateImports(sourceFile);
142
122
  replacements.push(...importReplacements);
143
- // Count usages
144
123
  replacements.forEach((r) => {
145
124
  const count = symbolUsageCounts.get(r.oldName) || 0;
146
125
  symbolUsageCounts.set(r.oldName, count + 1);
147
126
  });
148
- // Apply replacements in reverse order
149
127
  let result = content;
150
128
  replacements.sort((a, b) => b.start - a.start);
151
129
  for (const { start, end, replacement } of replacements) {
@@ -153,6 +131,26 @@ function renameSymbolsInFile(content, filePath, symbolUsageCounts) {
153
131
  }
154
132
  return result;
155
133
  }
134
+ function createBindingChecker(sourceFile) {
135
+ const options = { noLib: true, noResolve: true, types: [] };
136
+ const host = ts.createCompilerHost(options);
137
+ host.getSourceFile = (fileName) => (fileName === sourceFile.fileName ? sourceFile : undefined);
138
+ host.fileExists = (fileName) => fileName === sourceFile.fileName;
139
+ host.readFile = (fileName) => (fileName === sourceFile.fileName ? sourceFile.text : undefined);
140
+ return ts.createProgram([sourceFile.fileName], options, host).getTypeChecker();
141
+ }
142
+ function resolveReferenceSymbol(checker, identifier) {
143
+ const parent = identifier.parent;
144
+ if (ts.isShorthandPropertyAssignment(parent) && parent.name === identifier) {
145
+ return checker.getShorthandAssignmentValueSymbol(parent);
146
+ }
147
+ if (ts.isExportSpecifier(parent)) {
148
+ return (parent.propertyName ?? parent.name) === identifier && !parent.parent.parent.moduleSpecifier
149
+ ? checker.getExportSpecifierLocalTargetSymbol(parent)
150
+ : undefined;
151
+ }
152
+ return checker.getSymbolAtLocation(identifier);
153
+ }
156
154
  function collectQueryNamespaceAliases(sourceFile) {
157
155
  const aliases = new Set();
158
156
  sourceFile.statements.forEach((statement) => {
@@ -287,14 +285,13 @@ function replaceNamespaceInFile(content, filePath) {
287
285
  if (!hasExperimentalQueryImport || !experimentalQueryAlias) {
288
286
  return applyReplacements(content, namespaceReplacements, namespaceReplacements.length);
289
287
  }
288
+ const getAliasMember = (node) => getNamespaceMember(node, new Set([experimentalQueryAlias]))?.member;
290
289
  // Second pass: collect used symbols
291
290
  function findUsages(node) {
292
291
  // Find usages: ExperimentalQuery.someSymbol or E.someSymbol (if aliased)
293
- if (ts.isPropertyAccessExpression(node) &&
294
- ts.isIdentifier(node.expression) &&
295
- node.expression.text === experimentalQueryAlias) {
296
- const symbolName = node.name.text;
297
- usedSymbols.add(symbolName);
292
+ const member = getAliasMember(node);
293
+ if (member) {
294
+ usedSymbols.add(member.text);
298
295
  }
299
296
  ts.forEachChild(node, findUsages);
300
297
  }
@@ -352,13 +349,12 @@ function replaceNamespaceInFile(content, filePath) {
352
349
  }
353
350
  }
354
351
  // Replace usages: ExperimentalQuery.someSymbol → someSymbol
355
- if (ts.isPropertyAccessExpression(node) &&
356
- ts.isIdentifier(node.expression) &&
357
- node.expression.text === experimentalQueryAlias) {
352
+ const member = getAliasMember(node);
353
+ if (member) {
358
354
  replacements.push({
359
355
  start: node.getStart(sourceFile),
360
356
  end: node.getEnd(),
361
- replacement: node.name.text,
357
+ replacement: member.text,
362
358
  });
363
359
  }
364
360
  ts.forEachChild(node, createReplacements);
@@ -391,6 +387,271 @@ function applyReplacements(content, replacements, replacementCount) {
391
387
  });
392
388
  return { content: result, modified: replacements.length > 0, replacementCount };
393
389
  }
390
+ //#endregion
391
+ //#region withArgs null returns
392
+ function reportWithArgsNullReturns(tree) {
393
+ const sites = [];
394
+ const uncheckedSites = [];
395
+ visitQueryFiles(tree, ['withArgs'], (filePath, content) => {
396
+ const { nullLines, uncheckedLines } = findWithArgsNullReturns(createSourceFile(content, filePath));
397
+ nullLines.forEach((line) => sites.push(` - ${filePath}:${line}`));
398
+ uncheckedLines.forEach((line) => uncheckedSites.push(` - ${filePath}:${line}`));
399
+ });
400
+ if (sites.length > 0) {
401
+ console.warn(`\nāš ļø These withArgs callbacks return null. Before v3, null kept the previous args; v3 parks the query instead (args, response and executionState become null, and polling pauses). Check each one: a null that should park the query is fine, one that relied on keeping the previous args must now return those args itself:\n${sites.join('\n')}`);
402
+ }
403
+ if (uncheckedSites.length > 0) {
404
+ console.warn(`\nāš ļø TODO: These withArgs callbacks are passed by reference from somewhere this file does not declare, so they could not be checked for a null return. Check each one by hand - v3 parks the query on null, where earlier versions kept the previous args:\n${uncheckedSites.join('\n')}`);
405
+ }
406
+ }
407
+ const isCheckableFunction = (node) => !!node &&
408
+ (ts.isArrowFunction(node) ||
409
+ ts.isFunctionExpression(node) ||
410
+ ts.isFunctionDeclaration(node) ||
411
+ ts.isMethodDeclaration(node)) &&
412
+ !!node.body;
413
+ function resolveCallbackReference(checker, source) {
414
+ const name = ts.isPropertyAccessExpression(source) ? source.name : source;
415
+ if (!ts.isIdentifier(name))
416
+ return undefined;
417
+ const declaration = checker.getSymbolAtLocation(name)?.valueDeclaration;
418
+ if (isCheckableFunction(declaration))
419
+ return declaration;
420
+ if (!declaration || !(ts.isVariableDeclaration(declaration) || ts.isPropertyDeclaration(declaration))) {
421
+ return undefined;
422
+ }
423
+ const initializer = declaration.initializer && skipOuterExpressions(declaration.initializer);
424
+ if (isCheckableFunction(initializer))
425
+ return initializer;
426
+ if (initializer &&
427
+ ts.isCallExpression(initializer) &&
428
+ ts.isIdentifier(initializer.expression) &&
429
+ initializer.expression.text === 'computed') {
430
+ const [computation] = initializer.arguments;
431
+ return isCheckableFunction(computation) ? computation : undefined;
432
+ }
433
+ return undefined;
434
+ }
435
+ function skipOuterExpressions(expression) {
436
+ return ts.isParenthesizedExpression(expression) ||
437
+ ts.isAsExpression(expression) ||
438
+ ts.isSatisfiesExpression(expression)
439
+ ? skipOuterExpressions(expression.expression)
440
+ : expression;
441
+ }
442
+ function findWithArgsNullReturns(sourceFile) {
443
+ const checker = createBindingChecker(sourceFile);
444
+ const withArgsImports = new Set();
445
+ const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
446
+ const nullLines = [];
447
+ const uncheckedLines = [];
448
+ const lineOf = (node) => sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
449
+ sourceFile.statements.forEach((statement) => {
450
+ getQueryNamedImports(statement)?.elements.forEach((element) => {
451
+ const symbol = checker.getSymbolAtLocation(element.name);
452
+ if ((element.propertyName ?? element.name).text === 'withArgs' && symbol)
453
+ withArgsImports.add(symbol);
454
+ });
455
+ });
456
+ const isWithArgsCallee = (callee) => {
457
+ if (ts.isIdentifier(callee)) {
458
+ const symbol = checker.getSymbolAtLocation(callee);
459
+ return !!symbol && withArgsImports.has(symbol);
460
+ }
461
+ return getNamespaceMember(callee, namespaceAliases)?.member.text === 'withArgs';
462
+ };
463
+ function visit(node) {
464
+ if (ts.isCallExpression(node) && isWithArgsCallee(node.expression)) {
465
+ const [source] = node.arguments;
466
+ const callback = source && (isCheckableFunction(source) ? source : resolveCallbackReference(checker, source));
467
+ if (callback) {
468
+ collectNullReturns(callback).forEach((nullNode) => nullLines.push(lineOf(nullNode)));
469
+ }
470
+ else if (source) {
471
+ uncheckedLines.push(lineOf(source));
472
+ }
473
+ }
474
+ ts.forEachChild(node, visit);
475
+ }
476
+ visit(sourceFile);
477
+ return { nullLines: [...new Set(nullLines)].sort((a, b) => a - b), uncheckedLines };
478
+ }
479
+ function collectNullReturns(fn) {
480
+ if (!ts.isBlock(fn.body)) {
481
+ return findNullResults(fn.body);
482
+ }
483
+ const found = [];
484
+ function visit(node) {
485
+ if (ts.isFunctionLike(node))
486
+ return;
487
+ if (ts.isReturnStatement(node) && node.expression) {
488
+ found.push(...findNullResults(node.expression));
489
+ }
490
+ ts.forEachChild(node, visit);
491
+ }
492
+ ts.forEachChild(fn.body, visit);
493
+ return found;
494
+ }
495
+ function findNullResults(expression) {
496
+ if (expression.kind === ts.SyntaxKind.NullKeyword)
497
+ return [expression];
498
+ if (ts.isParenthesizedExpression(expression) ||
499
+ ts.isAsExpression(expression) ||
500
+ ts.isSatisfiesExpression(expression) ||
501
+ ts.isTypeAssertionExpression(expression)) {
502
+ return findNullResults(expression.expression);
503
+ }
504
+ if (ts.isConditionalExpression(expression)) {
505
+ return [...findNullResults(expression.whenTrue), ...findNullResults(expression.whenFalse)];
506
+ }
507
+ if (ts.isBinaryExpression(expression) &&
508
+ [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.AmpersandAmpersandToken].includes(expression.operatorToken.kind)) {
509
+ return findNullResults(expression.right);
510
+ }
511
+ return [];
512
+ }
513
+ //#endregion
514
+ //#region CLEAR_QUERY_ARGS replacement
515
+ const CLEAR_QUERY_ARGS_NAMES = new Set(['CLEAR_QUERY_ARGS', 'ClearQueryArgs']);
516
+ function replaceClearQueryArgs(tree) {
517
+ const updatedFiles = [];
518
+ const reExports = [];
519
+ visitQueryFiles(tree, [...CLEAR_QUERY_ARGS_NAMES], (filePath, content) => {
520
+ const result = replaceClearQueryArgsInFile(content, filePath);
521
+ if (result.content !== content) {
522
+ tree.write(filePath, result.content);
523
+ updatedFiles.push(filePath);
524
+ }
525
+ result.reExportLines.forEach((line) => reExports.push(` - ${filePath}:${line}`));
526
+ });
527
+ if (updatedFiles.length > 0) {
528
+ console.log(`\nšŸ…æļø Replaced CLEAR_QUERY_ARGS with null (the v3 park value) in ${updatedFiles.length} files: ${updatedFiles.join(', ')}`);
529
+ }
530
+ if (reExports.length > 0) {
531
+ console.warn(`\nāš ļø These files re-exported CLEAR_QUERY_ARGS or ClearQueryArgs. They now export a local \`null\` under the same name, so their importers keep compiling. Replace each import of it with \`null\`, then delete the export:\n${reExports.join('\n')}`);
532
+ }
533
+ }
534
+ function replaceClearQueryArgsInFile(content, filePath) {
535
+ const sourceFile = createSourceFile(content, filePath);
536
+ const checker = createBindingChecker(sourceFile);
537
+ const namespaceAliases = collectQueryNamespaceAliases(sourceFile);
538
+ const clearImports = new Map();
539
+ const replacements = [];
540
+ const reExportLines = [];
541
+ const statementEnd = (statement) => content[statement.getEnd()] === '\n' ? statement.getEnd() + 1 : statement.getEnd();
542
+ sourceFile.statements.forEach((statement) => {
543
+ const namedBindings = getQueryNamedImports(statement);
544
+ if (!namedBindings)
545
+ return;
546
+ const kept = namedBindings.elements.filter((element) => {
547
+ const importedName = (element.propertyName ?? element.name).text;
548
+ if (!CLEAR_QUERY_ARGS_NAMES.has(importedName))
549
+ return true;
550
+ const symbol = checker.getSymbolAtLocation(element.name);
551
+ if (symbol)
552
+ clearImports.set(symbol, importedName);
553
+ return false;
554
+ });
555
+ if (kept.length === namedBindings.elements.length)
556
+ return;
557
+ const importClause = statement.importClause;
558
+ if (kept.length === 0 && !importClause?.name) {
559
+ replacements.push({ start: statement.getStart(sourceFile), end: statementEnd(statement), replacement: '' });
560
+ return;
561
+ }
562
+ replacements.push({
563
+ start: namedBindings.getStart(sourceFile),
564
+ end: namedBindings.getEnd(),
565
+ replacement: `{ ${kept.map((element) => element.getText(sourceFile)).join(', ')} }`,
566
+ });
567
+ });
568
+ sourceFile.statements.forEach((statement) => {
569
+ if (!ts.isExportDeclaration(statement) || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) {
570
+ return;
571
+ }
572
+ const fromQuery = isQueryModuleSpecifier(statement.moduleSpecifier);
573
+ if (statement.moduleSpecifier && !fromQuery)
574
+ return;
575
+ const clearedName = (element) => {
576
+ if (fromQuery) {
577
+ const exportedName = (element.propertyName ?? element.name).text;
578
+ return CLEAR_QUERY_ARGS_NAMES.has(exportedName) ? exportedName : undefined;
579
+ }
580
+ const symbol = checker.getExportSpecifierLocalTargetSymbol(element);
581
+ return symbol && clearImports.get(symbol);
582
+ };
583
+ const elements = statement.exportClause.elements;
584
+ const nullExports = elements.flatMap((element) => {
585
+ const name = clearedName(element);
586
+ if (!name)
587
+ return [];
588
+ return [`export ${name === 'ClearQueryArgs' ? 'type' : 'const'} ${element.name.text} = null;`];
589
+ });
590
+ if (nullExports.length === 0)
591
+ return;
592
+ reExportLines.push(sourceFile.getLineAndCharacterOfPosition(statement.getStart(sourceFile)).line + 1);
593
+ const kept = elements.filter((element) => !clearedName(element));
594
+ if (kept.length === 0) {
595
+ replacements.push({
596
+ start: statement.getStart(sourceFile),
597
+ end: statement.getEnd(),
598
+ replacement: nullExports.join('\n'),
599
+ });
600
+ return;
601
+ }
602
+ replacements.push({
603
+ start: statement.exportClause.getStart(sourceFile),
604
+ end: statement.exportClause.getEnd(),
605
+ replacement: `{ ${kept.map((element) => element.getText(sourceFile)).join(', ')} }`,
606
+ });
607
+ replacements.push({
608
+ start: statement.getEnd(),
609
+ end: statement.getEnd(),
610
+ replacement: `\n${nullExports.join('\n')}`,
611
+ });
612
+ });
613
+ function visit(node) {
614
+ const namespaceMember = getNamespaceMember(node, namespaceAliases)?.member;
615
+ const referencesImport = () => {
616
+ const symbol = ts.isIdentifier(node) ? resolveReferenceSymbol(checker, node) : undefined;
617
+ return !!symbol && clearImports.has(symbol);
618
+ };
619
+ if ((namespaceMember && CLEAR_QUERY_ARGS_NAMES.has(namespaceMember.text)) || referencesImport()) {
620
+ const target = ts.isTypeQueryNode(node.parent) ? node.parent : node;
621
+ const replacement = ts.isShorthandPropertyAssignment(node.parent) ? `${node.getText(sourceFile)}: null` : 'null';
622
+ replacements.push({ start: target.getStart(sourceFile), end: target.getEnd(), replacement });
623
+ return;
624
+ }
625
+ ts.forEachChild(node, visit);
626
+ }
627
+ sourceFile.statements.forEach((statement) => {
628
+ if (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement))
629
+ visit(statement);
630
+ });
631
+ return { content: applyReplacements(content, replacements, replacements.length).content, reExportLines };
632
+ }
633
+ function isQueryModuleSpecifier(node) {
634
+ return !!node && ts.isStringLiteral(node) && node.text === '@ethlete/query';
635
+ }
636
+ function visitQueryFiles(tree, markers, visitor) {
637
+ visitNotIgnoredFiles(tree, '', (filePath) => {
638
+ if (!filePath.endsWith('.ts') || filePath.endsWith('.spec.ts'))
639
+ return;
640
+ const content = tree.read(filePath, 'utf-8');
641
+ if (!content?.includes('@ethlete/query') || !markers.some((marker) => content.includes(marker)))
642
+ return;
643
+ visitor(filePath, content);
644
+ });
645
+ }
646
+ function getQueryNamedImports(statement) {
647
+ if (!ts.isImportDeclaration(statement) ||
648
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
649
+ statement.moduleSpecifier.text !== '@ethlete/query') {
650
+ return undefined;
651
+ }
652
+ const namedBindings = statement.importClause?.namedBindings;
653
+ return namedBindings && ts.isNamedImports(namedBindings) ? namedBindings : undefined;
654
+ }
394
655
  const isRenamedSymbol = (name) => TYPE_RENAMES.has(name) || FUNCTION_RENAMES.has(name);
395
656
  function reportPrebuiltPackagesImportingRenamedSymbols(tree) {
396
657
  if (!tree.exists('package.json'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/query",
3
- "version": "6.0.0-next.52",
3
+ "version": "6.0.0-next.53",
4
4
  "license": "MIT",
5
5
  "sideEffects": false,
6
6
  "peerDependencies": {
@@ -124,7 +124,7 @@ type AuthTestSetup<TLoginArgs extends {
124
124
  ], TFeatures, TBearerData>;
125
125
  /** Helper to login a user and flush the HTTP request */
126
126
  login: (credentials: TLoginArgs['body'], response: TLoginArgs['response']) => void;
127
- /** Helper to trigger a refresh and flush the HTTP request */
127
+ /** Helper to trigger a refresh and flush the HTTP request. Sends `buildRefreshArgs(token)` when configured, else `{ body: { token } }` */
128
128
  refresh: (token: string, response: TRefreshArgs['response']) => void;
129
129
  /** Helper to make a secure request that requires authentication */
130
130
  makeSecureRequest: (route: string) => void;
@@ -184,9 +184,9 @@ type FakeBroadcastChannelHandle = {
184
184
  * Replaces `globalThis.BroadcastChannel` with an in-memory bus.
185
185
  *
186
186
  * Messages are structured-cloned at post time (so a non-cloneable payload throws exactly like the
187
- * real API does), delivered on a microtask (never synchronously), and never delivered back to the
188
- * channel that posted them - all three matching browser behavior. `await Promise.resolve()` in a
189
- * spec is enough to flush delivery.
187
+ * real API does), delivered as a task (never synchronously, and after every pending microtask), and
188
+ * never delivered back to the channel that posted them - all three matching browser behavior. Delivery
189
+ * does not depend on fake timers; `await flushMultiTabSync()` in a spec flushes it.
190
190
  */
191
191
  declare const installFakeBroadcastChannel: () => FakeBroadcastChannelHandle;
192
192
  type FakeWebLocksHandle = {
@@ -211,9 +211,9 @@ type FakeWebLocksHandle = {
211
211
  */
212
212
  declare const installFakeWebLocks: () => FakeWebLocksHandle;
213
213
  /**
214
- * Flushes the microtask queue, which is where both fakes schedule their work - message delivery and
215
- * lock grants. Awaiting it once is enough for a single hop; a grant that has to wait for another
216
- * tab's release needs one await per hop.
214
+ * Waits for the fakes' pending work - message delivery (a task) and lock grants (a microtask) - even
215
+ * under fake timers. Awaiting it once is enough for a single hop; a grant that has to wait for another
216
+ * tab's release, or a reply to a delivered message, needs one await per hop.
217
217
  */
218
218
  declare const flushMultiTabSync: () => Promise<void>;
219
219
 
@@ -247,7 +247,7 @@ type FakeQueryPersistenceStoreHandle = {
247
247
  /**
248
248
  * Holds every subsequent `read` pending until {@link flushReads} is called, for testing the window
249
249
  * between a cold mount and its hydration (a slow disk, or a mount that happened before startup
250
- * finished).
250
+ * finished). Each held read returns what the store held when it started, as `loadIndex` does.
251
251
  */
252
252
  deferReads: () => void;
253
253
  /** Settles the reads held by {@link deferReads} and stops deferring. */
@@ -295,10 +295,16 @@ type WebSocketTestDouble = {
295
295
  event: string;
296
296
  data: unknown;
297
297
  }[];
298
- /** Whether the client asked the socket to connect, and whether it has since disconnected it. */
298
+ /**
299
+ * Whether the client asked the socket to connect, and whether it has since disconnected it. `active` is
300
+ * socket.io's own flag - `false` after a server disconnect or a rejected handshake, until the next
301
+ * `connect()`; `connectCalls` counts every `connect()`.
302
+ */
299
303
  state: () => {
300
304
  connectRequested: boolean;
301
305
  disconnected: boolean;
306
+ active: boolean;
307
+ connectCalls: number;
302
308
  };
303
309
  /**
304
310
  * Complete the handshake: flush every buffered emit, then turn the client's `isConnected` true. Pass
@@ -307,8 +313,19 @@ type WebSocketTestDouble = {
307
313
  serverConnect: (options?: {
308
314
  recovered?: boolean;
309
315
  }) => void;
310
- /** Drop the connection, so `isConnected` turns false. */
311
- serverDisconnect: () => void;
316
+ /**
317
+ * Drop the connection, so `isConnected` turns false. The default reason, `'transport close'`, is one
318
+ * socket.io recovers from itself; `'io server disconnect'` is the server kicking the socket, after which
319
+ * socket.io only reconnects when `connect()` is called again.
320
+ */
321
+ serverDisconnect: (options?: {
322
+ reason?: 'transport close' | 'io server disconnect';
323
+ }) => void;
324
+ /**
325
+ * Reject the handshake the way a server middleware does: the `auth` payload is recorded in
326
+ * {@link handshakes}, `connect_error` fires and the socket stops reconnecting on its own.
327
+ */
328
+ serverRejectHandshake: (message?: string) => void;
312
329
  /**
313
330
  * Let the ping expire: the socket still reports itself connected, but buffers every emit like socket.io
314
331
  * does until it notices and closes. Follow it with {@link serverDisconnect}.