diff --git a/.eslintrc.json b/.eslintrc.json index bcc6d3615..8e2c0a637 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -25,8 +25,8 @@ ], "plugins": ["@typescript-eslint", "prettier", "prefer-arrow", "local-rules"], "rules": { - "local-rules/require-hydration-guard": "warn", - "local-rules/require-entity-registry": "warn", + "local-rules/require-hydration-guard": "error", + "local-rules/require-entity-registry": "error", "@typescript-eslint/no-explicit-any": 0, "@typescript-eslint/ban-ts-comment": 0, "@typescript-eslint/no-empty-function": 0, diff --git a/eslint-local-rules/rules/require-hydration-guard.js b/eslint-local-rules/rules/require-hydration-guard.js index ea92e84fa..f165cd4e9 100644 --- a/eslint-local-rules/rules/require-hydration-guard.js +++ b/eslint-local-rules/rules/require-hydration-guard.js @@ -24,6 +24,8 @@ * `withLatestFrom(this.store.select(...))` to get current state * - Selectors inside operator callbacks (e.g., inside `switchMap`) * - Selectors passed to `withLatestFrom`, `combineLatest`, etc. as secondary sources + * - Effects with `{ dispatch: false }` option - they only perform side effects (audio, UI) + * and never dispatch actions that could create duplicate operations during sync * * ## Examples * @@ -352,6 +354,32 @@ module.exports = { return results; }; + /** + * Check if the createEffect options object has dispatch: false + */ + const hasDispatchFalse = (node) => { + // node is a CallExpression for createEffect() + // Second argument is the options object: createEffect(() => ..., { dispatch: false }) + if (node.arguments.length < 2) return false; + + const options = node.arguments[1]; + if (!options || options.type !== 'ObjectExpression') return false; + + for (const prop of options.properties) { + if ( + prop.type === 'Property' && + prop.key.type === 'Identifier' && + prop.key.name === 'dispatch' && + prop.value.type === 'Literal' && + prop.value.value === false + ) { + return true; + } + } + + return false; + }; + /** * Check if a createEffect call contains unguarded selector usage */ @@ -359,6 +387,9 @@ module.exports = { // node is a CallExpression for createEffect() if (node.arguments.length === 0) return; + // Skip effects with { dispatch: false } - they don't dispatch actions during sync + if (hasDispatchFalse(node)) return; + const effectFn = node.arguments[0]; if (!effectFn) return; diff --git a/src/app/features/focus-mode/store/focus-mode.effects.ts b/src/app/features/focus-mode/store/focus-mode.effects.ts index 1ba0ba0f5..118e4731a 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.ts @@ -61,6 +61,7 @@ export class FocusModeEffects { this.store.select(selectFocusModeConfig), this.store.select(selectIsFocusModeEnabled), ]).pipe( + skipWhileApplyingRemoteOps(), switchMap(([cfg, isFocusModeEnabled]) => isFocusModeEnabled && cfg?.isSyncSessionWithTracking && !cfg?.isStartInBackground ? this.taskService.currentTaskId$.pipe( diff --git a/src/app/features/idle/store/idle.effects.ts b/src/app/features/idle/store/idle.effects.ts index e3b12fc98..010169916 100644 --- a/src/app/features/idle/store/idle.effects.ts +++ b/src/app/features/idle/store/idle.effects.ts @@ -107,6 +107,7 @@ export class IdleEffects { // while the dialog is open, preventing the flickering between two different values. triggerIdleWhenEnabled$ = createEffect(() => this._store.select(selectIdleConfig).pipe( + skipWhileApplyingRemoteOps(), switchMap( ({ isEnableIdleTimeTracking, diff --git a/src/app/op-log/testing/integration/lww-update-store-application.integration.spec.ts b/src/app/op-log/testing/integration/lww-update-store-application.integration.spec.ts index 0122fab6a..647a92e61 100644 --- a/src/app/op-log/testing/integration/lww-update-store-application.integration.spec.ts +++ b/src/app/op-log/testing/integration/lww-update-store-application.integration.spec.ts @@ -513,6 +513,7 @@ describe('LWW Update Store Application Integration', () => { type: '[UNKNOWN_TYPE] LWW Update', id: 'unknown1', title: 'Unknown', + // eslint-disable-next-line local-rules/require-entity-registry -- intentionally testing unknown entity type handling meta: { isPersistent: true, entityType: 'UNKNOWN_TYPE', entityId: 'unknown1' }, }; diff --git a/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts b/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts index 67789443b..33d62fe11 100644 --- a/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts +++ b/src/app/root-store/meta/task-shared-meta-reducers/lww-update.meta-reducer.spec.ts @@ -307,6 +307,7 @@ describe('lwwUpdateMetaReducer', () => { type: '[UNKNOWN_ENTITY] LWW Update', id: 'unknown-1', title: 'Updated Title', + // eslint-disable-next-line local-rules/require-entity-registry -- intentionally testing unknown entity type handling meta: { isPersistent: true, entityType: 'UNKNOWN_ENTITY', entityId: 'unknown-1' }, };