From e04999c065ee0c5201fe77f48a25b01c9134cc66 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 10 Jun 2026 16:35:05 -0400 Subject: [PATCH 1/7] feat: implement ETag middleware for Tasks page caching --- .../Http/Middleware/Etag/TasksPageEtag.php | 77 +++++ .../Http/Resources/Caching/TasksPageEtag.php | 303 ++++++++++++++++++ bootstrap/app.php | 1 + 3 files changed, 381 insertions(+) create mode 100644 ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php create mode 100644 ProcessMaker/Http/Resources/Caching/TasksPageEtag.php diff --git a/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php b/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php new file mode 100644 index 0000000000..4a43e3ed4d --- /dev/null +++ b/ProcessMaker/Http/Middleware/Etag/TasksPageEtag.php @@ -0,0 +1,77 @@ +isMethod('GET') && !$request->isMethod('HEAD'))) { + return $next($request); + } + + $etag = $this->tasksPageEtag->getEtag($request); + + if ($this->buildResponseWithEtag($etag)->isNotModified($request)) { + return $this->withPrivateCacheHeaders($this->buildNotModifiedResponse($etag, $request)); + } + + $response = $next($request); + $response->setEtag($etag); + + return $this->withPrivateCacheHeaders($response); + } + + /** + * Build a framework-compatible 304 response for a matched Tasks page ETag. + */ + private function buildNotModifiedResponse(string $etag, Request $request): Response + { + $response = $this->buildResponseWithEtag($etag); + $response->isNotModified($request); + + return $response; + } + + /** + * Create a response carrying the Tasks page validator. + * + * Weak ETags are used because the HTML may be transformed by gzip while the + * rendered representation remains equivalent for browser revalidation. + */ + private function buildResponseWithEtag(string $etag): Response + { + $response = new Response(); + $response->setEtag($etag, true); + + return $response; + } + + /** + * Apply browser-cache headers that allow private conditional revalidation. + */ + private function withPrivateCacheHeaders(Response $response): Response + { + $response->headers->set('Cache-Control', 'private, must-revalidate'); + $response->headers->remove('Pragma'); + $response->headers->remove('Expires'); + + return $response; + } +} diff --git a/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php new file mode 100644 index 0000000000..82340702c5 --- /dev/null +++ b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php @@ -0,0 +1,303 @@ +hashAlgorithm(), json_encode($this->payload($request))) . '"'; + } + + /** + * Build the complete content-affecting context used to validate the page. + * + * Volatile values such as CSRF tokens, session ids, and randomized asset URLs are + * intentionally excluded because they would prevent useful conditional requests. + */ + private function payload(Request $request): array + { + $user = $request->user(); + + return [ + 'route' => [ + 'name' => $request->route()?->getName(), + 'path' => $request->path(), + 'router' => $request->route('router'), + 'query' => $this->sorted($request->query()), + ], + 'user' => $this->userPayload($user), + 'tenant' => $this->tenantPayload(), + 'permissions_v' => $this->permissionsVersion($user), + 'session_content' => [ + 'alert' => session('_alert'), + 'rememberme' => session('rememberme'), + ], + 'saved_search_v' => $this->savedSearchPayload($user), + 'locale' => app()->getLocale(), + 'task_context' => [ + 'default_columns' => DefaultColumns::get('tasks'), + 'user_filter' => $user ? SaveSession::getConfigFilter('taskFilter', $user) : null, + 'user_configuration' => $this->userConfigurationPayload($user), + 'task_drafts_enabled' => TaskDraft::draftsEnabled(), + ], + 'features_v' => $this->featuresPayload(), + 'packages_v' => $this->packagesPayload(), + ]; + } + + /** + * Capture user fields emitted into the layout or used by Tasks page decisions. + */ + private function userPayload(?User $user): ?array + { + if (!$user) { + return null; + } + + return [ + 'id' => $user->id, + 'uuid' => $user->uuid, + 'updated_at' => $this->dateValue($user->updated_at), + 'is_administrator' => $user->is_administrator, + 'status' => $user->status, + 'fullname' => $user->fullname, + 'avatar' => $user->avatar, + 'datetime_format' => $user->datetime_format, + 'timezone' => $user->timezone, + 'language' => $user->language, + ]; + } + + /** + * Include tenant identity because tenant config and assets can change the page shell. + */ + private function tenantPayload(): ?array + { + $tenant = app()->bound('currentTenant') ? app('currentTenant') : null; + + if (!$tenant) { + return null; + } + + return [ + 'id' => $tenant->id ?? null, + 'updated_at' => $this->dateValue($tenant->updated_at ?? null), + ]; + } + + /** + * Version the effective permission context used by Blade and frontend props. + */ + private function permissionsVersion(?User $user): ?array + { + if (!$user) { + return null; + } + + $permissions = $user->is_administrator + ? Permission::query()->pluck('name')->all() + : app(PermissionServiceManager::class)->getUserPermissions($user->id); + sort($permissions); + + return [ + 'is_administrator' => $user->is_administrator, + 'permissions' => $permissions, + 'session_permissions' => $this->sessionPermissions(), + 'groups_updated_at' => $this->dateValue( + GroupMember::where('member_type', User::class) + ->where('member_id', $user->id) + ->max('updated_at') + ), + ]; + } + + /** + * Include the current session permission snapshot used by legacy permission checks. + */ + private function sessionPermissions(): array + { + $permissions = session('permissions', []); + + if (!is_array($permissions)) { + return []; + } + + sort($permissions); + + return $permissions; + } + + /** + * Include the default Tasks saved search when the Saved Search package is installed. + */ + private function savedSearchPayload(?User $user): ?array + { + $class = 'ProcessMaker\\Package\\SavedSearch\\Models\\SavedSearch'; + if (!$user || !class_exists($class)) { + return null; + } + + $savedSearch = $class::firstSystemSearchFor($user, $class::KEY_TASKS); + if (!$savedSearch) { + return null; + } + + return [ + 'id' => $savedSearch->id, + 'updated_at' => $this->dateValue($savedSearch->updated_at), + 'columns' => $savedSearch->columns, + ]; + } + + /** + * Capture the user UI configuration rendered into Tasks page props. + */ + private function userConfigurationPayload(?User $user): array + { + if (!$user) { + return UserConfigurationController::DEFAULT_USER_CONFIGURATION; + } + + $configuration = UserConfiguration::where('user_id', $user->id)->first(); + if (!$configuration) { + return [ + 'updated_at' => null, + 'ui_configuration' => UserConfigurationController::DEFAULT_USER_CONFIGURATION, + ]; + } + + return [ + 'updated_at' => $this->dateValue($configuration->updated_at), + 'ui_configuration' => $configuration->ui_configuration, + ]; + } + + /** + * Capture selected config values and frontend asset versions used by the page shell. + */ + private function featuresPayload(): array + { + $features = []; + foreach (self::FEATURE_CONFIG_KEYS as $key) { + Arr::set($features, $key, config($key)); + } + + $features['mix_manifest'] = $this->fileVersion(public_path('mix-manifest.json')); + + return $features; + } + + /** + * Version installed package state so package-provided UI changes invalidate the page. + */ + private function packagesPayload(): array + { + $packages = app(PackageManager::class)->listPackages(); + sort($packages); + + $manifest = app(PackageManifest::class); + + return [ + 'app_version' => $this->appVersion(), + 'registered' => $packages, + 'manifest' => method_exists($manifest, 'list') ? $manifest->list() : $manifest->providers(), + 'composer_lock' => $this->fileVersion(base_path('composer.lock')), + ]; + } + + /** + * Read the ProcessMaker application version from composer metadata. + */ + private function appVersion(): ?string + { + $composer = json_decode(File::get(base_path('composer.json')), true); + + return $composer['version'] ?? null; + } + + /** + * Return a cheap version marker for files that affect rendered assets or packages. + */ + private function fileVersion(string $path): ?array + { + if (!File::exists($path)) { + return null; + } + + return [ + 'mtime' => File::lastModified($path), + 'hash' => hash_file($this->hashAlgorithm(), $path), + ]; + } + + /** + * Prefer xxh128 when available and fall back for older runtimes. + */ + private function hashAlgorithm(): string + { + return in_array('xxh128', hash_algos(), true) ? 'xxh128' : 'sha256'; + } + + /** + * Normalize nullable date values for deterministic JSON hashing. + */ + private function dateValue($value): ?string + { + return $value ? (string) $value : null; + } + + /** + * Recursively sort arrays so query-string order does not change the ETag. + */ + private function sorted(array $value): array + { + ksort($value); + + foreach ($value as $key => $item) { + if (is_array($item)) { + $value[$key] = $this->sorted($item); + } + } + + return $value; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index d4f47576d4..7f9b9519a8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -114,6 +114,7 @@ 'admin' => ProcessMakerMiddleware\IsAdmin::class, 'manager' => ProcessMakerMiddleware\IsManager::class, 'etag' => ProcessMakerMiddleware\Etag\HandleEtag::class, + 'tasks-page-etag' => ProcessMakerMiddleware\Etag\TasksPageEtag::class, 'file_size_check' => ProcessMakerMiddleware\FileSizeCheck::class, 'auth.basic' => Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'throttle' => Illuminate\Routing\Middleware\ThrottleRequests::class, From f6f6d041736f2c25e80e0ed0d3efa052da596e32 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 10 Jun 2026 16:35:39 -0400 Subject: [PATCH 2/7] feat: update inbox and tasks routes to use ETag middleware for improved caching --- routes/web.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/routes/web.php b/routes/web.php index c0cf1141bf..f14bd63f7e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -170,7 +170,10 @@ Route::get('modeler/{process}/inflight/{request?}', [ModelerController::class, 'inflight'])->name('modeler.inflight')->middleware('can:view,request'); Route::get('/', [HomeController::class, 'index'])->name('home'); - Route::get('/inbox/{router?}', [TaskController::class, 'index'])->where(['router' => '.*'])->name('inbox')->middleware('no-cache'); + Route::get('/inbox/{router?}', [TaskController::class, 'index']) + ->where(['router' => '.*']) + ->name('inbox') + ->middleware('tasks-page-etag'); Route::get('/redirect-to-intended', [HomeController::class, 'redirectToIntended'])->name('redirect_to_intended'); Route::post('/keep-alive', [LoginController::class, 'keepAlive'])->name('keep-alive'); @@ -206,7 +209,7 @@ Route::get('tasks/search', [TaskController::class, 'search'])->name('tasks.search'); Route::get('tasks', [TaskController::class, 'index']) ->name('tasks.index') - ->middleware('no-cache'); + ->middleware('tasks-page-etag'); Route::get('tasks/{task}/edit', [TaskController::class, 'edit'])->name('tasks.edit'); Route::get('tasks/{task}/edit/quickfill', [TaskController::class, 'quickFillEdit'])->name('tasks.edit.quickfill'); Route::get('tasks/{task}/edit/{preview}', [TaskController::class, 'edit'])->name('tasks.preview'); From df83b960399e7afcb2662b9aaceadfd0ac6633ce Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 10 Jun 2026 16:35:56 -0400 Subject: [PATCH 3/7] feat: enhance BrowserCache middleware to return response if ETag header is present --- ProcessMaker/Http/Middleware/BrowserCache.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ProcessMaker/Http/Middleware/BrowserCache.php b/ProcessMaker/Http/Middleware/BrowserCache.php index 4f632f86f2..39ebe90065 100644 --- a/ProcessMaker/Http/Middleware/BrowserCache.php +++ b/ProcessMaker/Http/Middleware/BrowserCache.php @@ -23,6 +23,10 @@ public function handle($request, Closure $next) return $response; } + if ($response->headers->has('ETag')) { + return $response; + } + $response->header('pragma', 'no-cache'); $response->header('Cache-Control', 'no-store'); From 7576098b613c3839428a11d51e4c935edb148b9c Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Wed, 10 Jun 2026 16:36:39 -0400 Subject: [PATCH 4/7] test: add ETag handling tests for Tasks page to ensure proper caching behavior --- tests/Feature/TasksTest.php | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/Feature/TasksTest.php b/tests/Feature/TasksTest.php index e63b2bb18d..6fb7aa3b57 100644 --- a/tests/Feature/TasksTest.php +++ b/tests/Feature/TasksTest.php @@ -7,6 +7,7 @@ use ProcessMaker\Models\ProcessRequestToken; use ProcessMaker\Models\ProcessTaskAssignment; use ProcessMaker\Models\User; +use ProcessMaker\Models\UserConfiguration; use Tests\Feature\Shared\RequestHelper; use Tests\TestCase; @@ -39,6 +40,79 @@ public function testIndex() $response->assertSee('Tasks'); } + public function testTasksPageSendsPrivateEtagHeaders() + { + $response = $this->webGet(self::TASKS_URL, []); + + $response->assertStatus(200); + $response->assertHeader('ETag'); + $this->assertCacheControlHasPrivateMustRevalidate($response); + $response->assertHeaderMissing('Pragma'); + $response->assertHeaderMissing('Expires'); + } + + public function testTasksPageReturnsNotModifiedWhenEtagMatches() + { + $response = $this->webGet(self::TASKS_URL, []); + $etag = $response->headers->get('ETag'); + + $responseWithMatchingEtag = $this->actingAs($this->user, 'web') + ->withHeaders(['If-None-Match' => $etag]) + ->get(self::TASKS_URL); + + $responseWithMatchingEtag->assertStatus(304); + $this->assertEquals( + $this->stripWeakEtagPrefix($etag), + $this->stripWeakEtagPrefix($responseWithMatchingEtag->headers->get('ETag')) + ); + $this->assertCacheControlHasPrivateMustRevalidate($responseWithMatchingEtag); + $this->assertEmpty($responseWithMatchingEtag->getContent()); + } + + public function testTasksPageEtagChangesWhenUserConfigurationChanges() + { + $response = $this->webGet(self::TASKS_URL, []); + $etag = $response->headers->get('ETag'); + + UserConfiguration::create([ + 'user_id' => $this->user->id, + 'ui_configuration' => json_encode([ + 'tasks' => [ + 'isMenuCollapse' => false, + ], + ]), + ]); + + $updatedResponse = $this->webGet(self::TASKS_URL, []); + + $this->assertNotEquals($etag, $updatedResponse->headers->get('ETag')); + } + + public function testTasksPageEtagChangesWhenFeatureConfigChanges() + { + $response = $this->webGet(self::TASKS_URL, []); + $etag = $response->headers->get('ETag'); + + config()->set('app.task_drafts_enabled', !config('app.task_drafts_enabled')); + + $updatedResponse = $this->webGet(self::TASKS_URL, []); + + $this->assertNotEquals($etag, $updatedResponse->headers->get('ETag')); + } + + private function assertCacheControlHasPrivateMustRevalidate($response): void + { + $cacheControl = $response->headers->get('Cache-Control'); + + $this->assertStringContainsString('private', $cacheControl); + $this->assertStringContainsString('must-revalidate', $cacheControl); + } + + private function stripWeakEtagPrefix(?string $etag): ?string + { + return $etag ? str_replace('W/', '', $etag) : null; + } + public function testViewTaskWithComments() { //Start a process request From 8b3959f9d9fc03bddae41b7f84663ee6886e2a47 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 11 Jun 2026 13:45:36 -0400 Subject: [PATCH 5/7] feat: optimize TasksPageEtag for permission handling and user configuration --- .../Http/Resources/Caching/TasksPageEtag.php | 106 +++++++++++++++--- 1 file changed, 88 insertions(+), 18 deletions(-) diff --git a/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php index 82340702c5..298f8d0b81 100644 --- a/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php +++ b/ProcessMaker/Http/Resources/Caching/TasksPageEtag.php @@ -5,17 +5,15 @@ use Illuminate\Foundation\PackageManifest; use Illuminate\Http\Request; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use ProcessMaker\Filters\SaveSession; -use ProcessMaker\Helpers\DefaultColumns; use ProcessMaker\Http\Controllers\Api\UserConfigurationController; use ProcessMaker\Managers\PackageManager; -use ProcessMaker\Models\GroupMember; -use ProcessMaker\Models\Permission; +use ProcessMaker\Models\Group; use ProcessMaker\Models\TaskDraft; use ProcessMaker\Models\User; use ProcessMaker\Models\UserConfiguration; -use ProcessMaker\Services\PermissionServiceManager; class TasksPageEtag { @@ -74,7 +72,6 @@ private function payload(Request $request): array 'saved_search_v' => $this->savedSearchPayload($user), 'locale' => app()->getLocale(), 'task_context' => [ - 'default_columns' => DefaultColumns::get('tasks'), 'user_filter' => $user ? SaveSession::getConfigFilter('taskFilter', $user) : null, 'user_configuration' => $this->userConfigurationPayload($user), 'task_drafts_enabled' => TaskDraft::draftsEnabled(), @@ -126,6 +123,10 @@ private function tenantPayload(): ?array /** * Version the effective permission context used by Blade and frontend props. + * + * The full permission list can be expensive to rebuild, so this uses the session + * snapshot plus lightweight assignment/version markers that are enough to + * invalidate when direct user or direct group permission assignments change. */ private function permissionsVersion(?User $user): ?array { @@ -133,19 +134,18 @@ private function permissionsVersion(?User $user): ?array return null; } - $permissions = $user->is_administrator - ? Permission::query()->pluck('name')->all() - : app(PermissionServiceManager::class)->getUserPermissions($user->id); - sort($permissions); + $directGroups = $this->directGroupPayload($user); return [ 'is_administrator' => $user->is_administrator, - 'permissions' => $permissions, 'session_permissions' => $this->sessionPermissions(), - 'groups_updated_at' => $this->dateValue( - GroupMember::where('member_type', User::class) - ->where('member_id', $user->id) - ->max('updated_at') + 'permissions_table' => $this->tableVersion('permissions'), + 'direct_user_permissions' => $this->assignablePermissionVersion(User::class, [$user->id]), + 'direct_groups' => $directGroups['ids'], + 'direct_group_memberships' => $directGroups['version'], + 'direct_group_permissions' => $this->assignablePermissionVersion( + Group::class, + $directGroups['ids'] ), ]; } @@ -184,7 +184,7 @@ private function savedSearchPayload(?User $user): ?array return [ 'id' => $savedSearch->id, 'updated_at' => $this->dateValue($savedSearch->updated_at), - 'columns' => $savedSearch->columns, + 'columns_hash' => $this->hashValue($savedSearch->columns), ]; } @@ -197,20 +197,90 @@ private function userConfigurationPayload(?User $user): array return UserConfigurationController::DEFAULT_USER_CONFIGURATION; } - $configuration = UserConfiguration::where('user_id', $user->id)->first(); + $configuration = UserConfiguration::select('updated_at', 'ui_configuration') + ->where('user_id', $user->id) + ->first(); if (!$configuration) { return [ 'updated_at' => null, - 'ui_configuration' => UserConfigurationController::DEFAULT_USER_CONFIGURATION, + 'ui_configuration_hash' => $this->hashValue(UserConfigurationController::DEFAULT_USER_CONFIGURATION), ]; } return [ 'updated_at' => $this->dateValue($configuration->updated_at), - 'ui_configuration' => $configuration->ui_configuration, + 'ui_configuration_hash' => $this->hashValue($configuration->ui_configuration), + ]; + } + + /** + * Return direct group ids and their version marker without loading group models. + */ + private function directGroupPayload(User $user): array + { + $memberships = DB::table('group_members') + ->where('member_type', User::class) + ->where('member_id', $user->id) + ->orderBy('group_id') + ->get(['group_id', 'updated_at']); + + return [ + 'ids' => $memberships->pluck('group_id')->all(), + 'version' => [ + 'count' => $memberships->count(), + 'updated_at' => $this->dateValue($memberships->max('updated_at')), + ], + ]; + } + + /** + * Hash direct permission assignment ids for an assignable type. + */ + private function assignablePermissionVersion(string $assignableType, array $assignableIds): array + { + if (empty($assignableIds)) { + return [ + 'count' => 0, + 'permission_ids_hash' => $this->hashValue([]), + ]; + } + + $permissionIds = DB::table('assignables') + ->where('assignable_type', $assignableType) + ->whereIn('assignable_id', $assignableIds) + ->orderBy('permission_id') + ->pluck('permission_id') + ->all(); + + return [ + 'count' => count($permissionIds), + 'permission_ids_hash' => $this->hashValue($permissionIds), + ]; + } + + /** + * Version a table with a compact count and updated_at marker. + */ + private function tableVersion(string $table): array + { + $version = DB::table($table) + ->selectRaw('COUNT(*) as count, MAX(updated_at) as updated_at') + ->first(); + + return [ + 'count' => (int) ($version->count ?? 0), + 'updated_at' => $this->dateValue($version->updated_at ?? null), ]; } + /** + * Hash structured values before placing them in the ETag payload. + */ + private function hashValue($value): string + { + return hash($this->hashAlgorithm(), json_encode($value)); + } + /** * Capture selected config values and frontend asset versions used by the page shell. */ From 6da1dec6c892abd5a5e42256d563b7b985f80713 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 11 Jun 2026 13:56:01 -0400 Subject: [PATCH 6/7] fix: update inbox route to use no-cache middleware for improved response handling --- routes/web.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/routes/web.php b/routes/web.php index f14bd63f7e..a8ca8fbb07 100644 --- a/routes/web.php +++ b/routes/web.php @@ -170,10 +170,7 @@ Route::get('modeler/{process}/inflight/{request?}', [ModelerController::class, 'inflight'])->name('modeler.inflight')->middleware('can:view,request'); Route::get('/', [HomeController::class, 'index'])->name('home'); - Route::get('/inbox/{router?}', [TaskController::class, 'index']) - ->where(['router' => '.*']) - ->name('inbox') - ->middleware('tasks-page-etag'); + Route::get('/inbox/{router?}', [TaskController::class, 'index'])->where(['router' => '.*'])->name('inbox')->middleware('no-cache'); Route::get('/redirect-to-intended', [HomeController::class, 'redirectToIntended'])->name('redirect_to_intended'); Route::post('/keep-alive', [LoginController::class, 'keepAlive'])->name('keep-alive'); From 127f020d99310ef8b8701b3699711854d5661414 Mon Sep 17 00:00:00 2001 From: Miguel Angel Date: Thu, 11 Jun 2026 13:57:32 -0400 Subject: [PATCH 7/7] docs: add Tasks Page ETag Sequence documentation --- docs/tasks-page-etag-sequence.md | 95 ++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/tasks-page-etag-sequence.md diff --git a/docs/tasks-page-etag-sequence.md b/docs/tasks-page-etag-sequence.md new file mode 100644 index 0000000000..68efe9c5cc --- /dev/null +++ b/docs/tasks-page-etag-sequence.md @@ -0,0 +1,95 @@ +# Tasks Page ETag Sequence + +This diagram captures the current request flow for the Tasks page shell (`/tasks`). The route-specific middleware computes a stable Tasks page ETag before rendering, short-circuits matching conditional requests with `304 Not Modified`, and sets private revalidation headers. The legacy inbox route (`/inbox/{router?}`) remains on the original `no-cache` middleware path and does not use this ETag flow. The global browser-cache middleware preserves ETag-enabled responses instead of applying `no-store`. + +![Tasks page ETag sequence](tasks-page-etag-sequence.svg) + +```mermaid +sequenceDiagram + autonumber + participant Browser as Browser + participant BrowserCache as BrowserCache
ProcessMaker\Http\Middleware\BrowserCache + participant Router as Laravel Router + participant TasksPageEtagMw as TasksPageEtag middleware
ProcessMaker\Http\Middleware\Etag\TasksPageEtag + participant Payload as TasksPageEtag payload
ProcessMaker\Http\Resources\Caching\TasksPageEtag + participant SymfonyResponse as Response
Symfony\Component\HttpFoundation\Response + participant Controller as TaskController@index
ProcessMaker\Http\Controllers\TaskController + + Browser->>BrowserCache: GET /tasks
Cookie + optional If-None-Match + BrowserCache->>Router: pass request through global middleware stack + Router->>TasksPageEtagMw: dispatch route with tasks-page-etag middleware + + alt ETags disabled or method is not GET/HEAD + TasksPageEtagMw->>Controller: bypass ETag logic + Controller-->>TasksPageEtagMw: normal page response + TasksPageEtagMw-->>BrowserCache: response without Tasks page ETag handling + else ETags enabled and method is GET/HEAD + TasksPageEtagMw->>Payload: getEtag(request) + Payload->>Payload: collect route name/path/router/query + Payload->>Payload: collect user and tenant version markers + Payload->>Payload: collect permission table/session/direct assignment markers + Payload->>Payload: collect saved search id/updated_at/columns hash + Payload->>Payload: collect user config hash and task drafts flag + Payload->>Payload: collect feature config, package list, manifest, asset versions + Payload->>Payload: exclude CSRF, session id, randomized favicon URL + Payload-->>TasksPageEtagMw: quoted stable hash + TasksPageEtagMw->>SymfonyResponse: create empty response + set weak ETag + TasksPageEtagMw->>SymfonyResponse: isNotModified(request) + + alt If-None-Match matches weak ETag + SymfonyResponse-->>TasksPageEtagMw: true + TasksPageEtagMw->>SymfonyResponse: build 304 response with same weak ETag + TasksPageEtagMw->>TasksPageEtagMw: Cache-Control: private, must-revalidate + TasksPageEtagMw->>TasksPageEtagMw: remove Pragma and Expires + TasksPageEtagMw-->>BrowserCache: 304 Not Modified + else Missing/stale If-None-Match + SymfonyResponse-->>TasksPageEtagMw: false + TasksPageEtagMw->>Controller: render Tasks page shell + Controller->>Controller: resolve title, router mode, mobile check + Controller->>Controller: load ScreenBuilderManager scripts + Controller->>Controller: load task filter, default columns, drafts flag + Controller->>Controller: load user configuration and default saved search + Controller-->>TasksPageEtagMw: tasks.index response + TasksPageEtagMw->>TasksPageEtagMw: attach weak ETag to 200 response + TasksPageEtagMw->>TasksPageEtagMw: Cache-Control: private, must-revalidate + TasksPageEtagMw->>TasksPageEtagMw: remove Pragma and Expires + TasksPageEtagMw-->>BrowserCache: 200 OK with weak ETag + end + end + + alt Response has ETag + BrowserCache->>BrowserCache: preserve response headers + BrowserCache->>BrowserCache: skip no-store / Pragma override + else No ETag and BROWSER_CACHE=false + BrowserCache->>BrowserCache: add Pragma: no-cache + BrowserCache->>BrowserCache: add Cache-Control: no-store + end + + BrowserCache-->>Browser: 200 OK + ETag or 304 Not Modified + Browser->>Browser: Store private validator and send If-None-Match on later reload +``` + +## ETag Context + +The payload intentionally includes content-affecting values: + +- Route path/query/router state. +- Authenticated user id, update timestamp, locale, timezone, display fields, and admin status. +- Tenant id and tenant update timestamp when present. +- Permission table version, session permission snapshot, direct user/group assignment hashes, and direct group membership version. +- Task filter cache, user configuration hash, task draft flag, and saved search defaults hash. +- Page-relevant feature config, registered package list, package manifest, app version, `composer.lock`, and `mix-manifest.json`. + +The payload intentionally excludes volatile values that do not define the rendered page, such as CSRF token, session id, and randomized favicon URLs. + +## Header Outcome + +Successful Tasks page responses should use private revalidation rather than storage blocking: + +```http +Cache-Control: private, must-revalidate +ETag: W/"..." +Vary: Accept-Encoding +``` + +They should not include `no-store` or `Pragma: no-cache`; otherwise the browser will not keep the validator and will not send `If-None-Match`.