Closes remaining coherence gaps around async writes that bypassed the
generation token introduced in 350d852cd:
- `teamRequestMoved` subscription captures the team generation before
awaiting `buildDestinationAncestorChain`, then re-checks it before
mutating tab `saveContext` or calling
`updateInheritedPropertiesForAffectedRequests`. A team switch landing
mid-fetch no longer routes the old-team's destination chain into the
new team's tabs.
- `buildDestinationAncestorChain` accepts an explicit generation and
bails out of the backend-fetch loop if it diverges, so the previous
team's ancestors can't be hydrated into the new team's cache.
- Add `getTeamGeneration()`, `isCurrentTeamGeneration()`, and
`setPendingTeamCollectionPathForGeneration()` on the service. The
collection-properties edit path in `components/collections/index.vue`
snapshots the generation before dispatching `updateTeamCollection`
and routes the success write through the service, so a completion
that races past a team switch is dropped instead of queuing a stale
path for the new team's loading watcher.
Closes three coherence gaps flagged in cross-model review:
- Add a monotonic `teamGeneration` token bumped on every `changeTeamID`
and `clearCollections`. `initialize`, `loadRootCollections`, and
`expandCollection` capture the token at dispatch and drop their
writes if the value diverges during an await, so a fast team switch
can no longer let stale fetches from the previous team repopulate
the current tree.
- Clear `pendingTeamCollectionPath` on both team-switch entry points so
a property-update path queued against the old team can't replay once
the new team's load reaches zero.
- Evict hydrated-ancestor cache entries using the union of the live
subtree IDs and the cache's parent-chain walk, seeded before
`deleteCollInTree` detaches the live subtree. Once an intermediate
ancestor has been expanded into the live tree, cache-only eviction
couldn't reach deeper cached descendants behind it; walking the live
subtree for the seed set closes that mixed-state gap.
Two remaining coherence gaps in the hydrated-ancestors side cache:
1. `changeTeamID` (the real team-to-team switch path used by
workspace.service) reset collections/entityIDs/subscriptions but
left `hydratedAncestors` populated, so ancestry from the previous
team could leak into cascade resolution on the new team.
2. Backend team collection delete cascades the entire subtree but the
`teamCollectionRemoved` subscription emits only the root ID.
Evicting just that one key left any cached hydrated descendants
reachable by later ancestry rebuilds and cascade reads.
Add `evictHydratedAncestorSubtree(rootID)` that seeds a doomed set
with the removed root and iteratively marks any cache entry whose
`parentID` chain passes through an already-doomed entry (small
cache, cheap). Call it from both the remove handler and on team
switch via `changeTeamID`.
The hydrated-ancestors side cache was write-once: populated on
collaborator-move ancestry resolution, never refreshed on later
remote events. A subsequent update/move/remove on a cached but still
unexpanded ancestor left the cache holding stale parentID / title /
data, which would then drive wrong destination chains and inherited
auth/headers.
- teamCollectionUpdated: refresh cached entry's title/data in place.
- teamCollectionMoved: refresh cached entry's parentID/title/data so
subsequent ancestry rebuilds walk the new parent link.
- teamCollectionRemoved: evict the cached entry entirely.
- clearCollections: clear the cache alongside collections / entityIDs
so team switches don't carry stale hydrations across teams.
expandCollection continues to evict cache entries whose real nodes
just landed in the live tree (added in the earlier commit).
Inserting hydrated nodes at the root of `this.collections.value` made
them render as duplicate root collections in the UI tree, and the
alternative (attaching to a loaded parent) broke the children===null
lazy-load sentinel. Neither placement worked.
Move hydration to a parallel `hydratedAncestors` Map keyed by
collection ID. The map stores the fetched `{id, title, data, children:
null, requests: null, parentID}` payload without touching
`this.collections.value` or `entityIDs`.
- `buildDestinationAncestorChain` walks leaf→root, consults the live
tree first, then the cache, and only fetches from backend when
neither source has the node. Cached entries carry their `parentID`
so we can traverse without re-hitting the backend.
- `cascadeParentCollectionForProperties` now reads each path segment
from `findCollInTree(...) ?? this.hydratedAncestors.get(...)`, so
inheritance cascades correctly across hydrated ancestors.
- `expandCollection` evicts any cache entry that just became
available in the live tree, keeping the cascade on authoritative
data once a real fetch completes.
This avoids the visible-duplicate-root and sentinel-break regressions
both Codex rounds flagged, while still closing the ancestry
reconstruction gap for collaborator moves into unexpanded subtrees.
The previous hydration pass wrote `parent.children = parent.children ?? []`,
flipping the "not yet expanded" sentinel used by `expandCollection()`.
That would make `expandCollection` believe the ancestor was already
expanded after a collaborator move and skip fetching its real children
next time the user opens that folder.
Only attach the hydrated node into the parent's children array when
that array is already loaded (`Array.isArray(parent.children)`). When
it is `null` (unexpanded) or the parent isn't in the tree at all,
insert the hydrated node at root. `findCollInTree` walks recursively
regardless, so the cascade helper still finds the node by ID for
inheritance resolution, and the parent's expansion sentinel stays
intact for the next real expand call.
Previous fix reconstructed the ID chain but `cascadeParentCollectionForProperties`
still calls `findCollInTree(this.collections.value, pathSegment)` per
ancestor — if any ancestor isn't hydrated locally, the cascade bails
with "Parent folder not found for path" and the tab loses its
inheritance.
Extend `buildDestinationAncestorChain` to also hydrate each missing
node from the backend's `getSingleCollection` response (id/title/data/
parent) and insert it into `this.collections.value` attached to its
parent (or at root if the parent isn't loaded — the cascade uses
recursive `findCollInTree` lookups, not structural nesting, so
placement only needs to make the ID resolvable). The subsequent
cascade now finds every ancestor node and correctly merges auth/
headers along the full ancestry.
The previous fix walked only `collections.value` to build the legacy
saveContext ancestor chain. If a collaborator moves a request into a
collection whose subtree has not yet been expanded in this session,
`this.collections.value` lacks the parent link and the chain collapses
back to the leaf UUID — losing ancestor auth/headers on legacy tabs.
Add a `buildDestinationAncestorChain` helper that walks local first
and falls back to `getSingleCollection(id)` when a parent link is
missing, so the chain is resolved correctly even for unexpanded
destination subtrees. The backend path is only hit when the local
walk can't proceed, and guarded against cycles / excessive depth.
Previous fix wrote `requestMoved.collectionID` (leaf UUID) into
legacy `team-collection` tab `saveContext.collectionID`. The cascade
helper walks that field as a slash-delimited chain of ancestor IDs,
calling `findCollInTree(collections, pathSegment)` per segment. A bare
leaf UUID means the helper iterates `[leafUUID]` and only inherits
from the leaf, losing all ancestor auth/headers on nested moves.
Construct the full ancestor chain from the post-move tree (leaf up to
root via `findParentOfColl`) and write that into saveContext. The
cascade then correctly merges auth/headers along the entire new
lineage, matching the shape the legacy path has always used.
The prior round's fix added an inherited-property refresh call in
`TeamCollectionsService.teamRequestMoved`, but the legacy tab's
saveContext.collectionID still pointed at the pre-move collection path
when the helper ran. The team-collection branch of the helper cascades
from the save-context's collectionID, so it either missed the moved tab
entirely or recomputed inheritance from the old ancestry.
Look up the legacy tab by requestID, rewrite its
saveContext.collectionID to the new destination, then invoke the
refresh helper so cascading auth/headers are sourced from the correct
collection. The new workspace-user-collection tabs keep their handle
in sync via the new-workspace subscription, so no rewrite is needed
for them.
The new-workspace provider's teamRequestMoved subscription now calls
`updateInheritedPropertiesForAffectedRequests` to refresh any open tabs
after a collaborator move. The legacy `TeamCollectionsService` handler
was still only mutating the tree without refreshing tab inheritance —
leaving open tabs with the legacy `team-collection` save context stale
after collaborator request moves.
Add the same refresh call from `TeamCollectionsService.teamRequestMoved`
after the local tree update. The helper's branches handle both the
legacy `team-collection` and new `workspace-user-collection` save
context shapes.
Two remaining gaps in the team-collection-move inheritance refresh:
1. The workspace-user-collection refresh path in
`updateInheritedPropertiesForAffectedRequests` used
`workspaceService.activeWorkspaceHandle`, so tabs whose request
belongs to a non-active workspace would be fetched against the wrong
workspace or skipped entirely. Resolve the workspace handle per-tab
via `workspaceService.getWorkspaceHandle(providerID, workspaceID)`
using the tab's own request-handle data.
2. Collaborative/remote *request* moves (via the `teamRequestMoved`
subscription) did not refresh open tab inheritedProperties —
symmetric gap to the collection-moved hookup. Add a call to the
same helper from the subscription handler after applying the local
move; the helper's widened workspace-user-collection filter picks
up the moved request's tab automatically.
The previous pass re-invoked `updateInheritedPropertiesForAffectedRequests`
from the local team drag handlers but missed two cases:
1. The helper's filter `collectionID.startsWith(path)` only catches tabs
whose save-context collection UUID begins with the moved UUID. Team
UUIDs don't encode ancestry, so descendant collections never match
and their open tabs stay stale.
2. Remote/collaborative moves (via the `teamCollectionMoved` subscription)
never invoked the refresh helper at all.
Widen the helper filter so every `workspace-user-collection` tab is
re-evaluated on a move — the per-tab refresh asks the workspace service
to recompute cascading auth/headers from the handle's current collection
anyway, so over-selecting is cheap and always correct. Invoke the same
helper from `setupTeamsCollectionMovedSubscription` so remote moves keep
open tabs in sync.
Three additional regressions uncovered by the next Codex round beyond the
initial 7-blocker fix:
1. rawData lifecycle was incomplete. The first pass populated `rawData`
only in bulk-fetch paths and in the collection-added subscription for
brand-new rows. Three gaps remained: (a) optimistic local creates
(createRESTRootCollection / createRESTChildCollection) pushed rows
with no `rawData`, (b) the collection-added subscription dedupe
dropped the authoritative backend data for already-present rows,
and (c) the collection-updated subscription refreshed auth/headers
but not the raw data blob. Any of those would let a later rename-only
update fall back to empty variables/description and wipe backend
state. Populate `rawData: null` on optimistic creates, backfill
auth/headers/rawData onto existing rows on the added subscription,
and refresh rawData on every updated subscription event.
2. Team collection moves left open request tabs with stale inherited
properties. The new tree returned before running the refresh helper
for teams, and the shared helper had no branch for the
`workspace-user-collection` save-context shape. Re-invoke the helper
from both team drag paths with the dragged collection's UUID as the
prefix; extend the helper to resolve cascading auth/headers via the
workspace service for workspace-user-collection tabs.
3. Properties OAuth resume bypassed the team guard. The post-OAuth
handler at the top of the component restored persisted
unsaved_collection_properties and reopened the modal without
checking the active workspace, and `setCollectionProperties` itself
was unguarded. Clear stale persisted state and surface the same
toast when the OAuth flow lands in a team workspace; add a
defense-in-depth check inside setCollectionProperties.
Test suite still green (724 passing).
The first pass of the teams `updateRESTCollection` fix pulled variables
and description off `updatedCollection` with `[]` / `null` fallbacks.
That still silently wiped backend state on partial updates like the
`onEditRootCollection` rename path which only passes `{ name }`.
Retain the raw backend `data` JSON blob on `TeamsWorkspaceCollection`
(populated from every subscription event and the full-workspace
fetch). On update, parse variables/description out of that blob when
the caller omits them, so rename and auth/header-only edits no longer
clobber the backend-stored values.
Also:
- Simplify the local `description` access in `updateRESTCollection` to
use `updatedCollection.description` directly (HoppCollection v11 has
the field declared, so the defensive cast was unnecessary)
- Short-circuit `dropCollection` for teams right after the move
succeeds, mirroring the `dropToRoot` pattern — avoids a wasted
`getRESTCollectionLevelAuthHeadersView` fetch when the teams path
skips post-move inheritance bookkeeping anyway
Seven runtime regressions in the new REST collections tree and related
save-as/Properties/drag flows, verified against HEAD 609dfe84e and
confirmed by cross-model review (Claude ↔ Codex convergence over 7
rounds):
1. updateInheritedPropertiesForAffectedRequests arity mismatch —
helper signature is (path, "rest" | "graphql") but three call sites
in the new REST tree passed three args, dropping "rest" into the
second slot. The function ran the GraphQL branch, no REST tabs
refreshed inherited auth/headers after collection-property saves or
moves. Affected ALL REST workspaces, not just teams.
2. teams provider updateRESTCollection hardcoded variables: [] and
description: null on every write, silently wiping backend state on
any partial update. Pull both from the updatedCollection payload;
document the partial-update constraint in the code.
3. editCollectionProperties cross-wired to personal store for teams —
navigateToFolderWithIndexPath(restCollectionState, parseInt(UUID)=NaN)
returned undefined, modal rendered with defaults, save overwrote
real auth/headers/variables. Guard the entry point for team
workspaces until the provider surface grows an editable view.
4. Team drag handlers used path-based bookkeeping — isAlreadyInRoot,
getFoldersByPath, getRequestsByPath, split/parseInt on UUIDs.
Drag-to-root was blocked for every team collection; drag-between
computed personal-store sibling counts for team moves; post-move
handle mutations overwrote real team IDs with synthetic paths. Skip
the personal-store bookkeeping path for teams; the teams provider's
subscription stream drives the subsequent refreshes.
5. Collection.vue editCollection / removeCollection used
collectionID.split("/").length > 1 as root/child detector —
misclassified every team collection as root. Use the view-node's
parentCollectionID (null for root) instead.
6. Team save-as in the new tree silently no-op'd via handle-lookup
rejection (teams provider expects UUIDs, got "0/1"-style index
paths). Surface a toast pointing users to the workaround.
7. Search tree adapter emitted synthetic index-path IDs that flowed
into UUID-only team handlers, breaking click-to-open, context menu
actions, and drag-reorder on team search results. Guard the tree
with a placeholder for teams until search IDs carry stable provider
identities.
All 724 tests in hoppscotch-common still pass.
The search tree adapter was typed `Ref<HoppCollection[]>` but the teams
provider forces a `Ref<TeamCollection[]>` into it via a double-cast
(`as unknown as Ref<HoppCollection[]>` in teams.workspace.ts:1472).
`HoppCollection` uses `folders` while `TeamCollection` uses `children`.
Root-level rendering worked because both types expose a top-level
`requests`, but expanding a nested team search hit would throw
`TypeError: Cannot read properties of undefined (reading 'map')` on
`item.folders.map`.
Normalize access inside the adapter via a union-safe shape that reads
either `folders` or `children`, and replace the personal-only
`navigateToFolderWithIndexPath` walk with a local walker using the same
accessor. Personal-workspace behavior is unchanged; team-workspace
expansion now walks the nested tree correctly.
WritableComputedRef setter only fires on .value assignment, not on
nested property mutation. Replace .value.data.requestID = ... with
full .value = { ...value, data: { ...data, ... } } to guarantee
reactive propagation through the writable computed layer.
Use IconUsers for team workspace items and IconCheck for the active
workspace indicator, matching the pattern in PersonalWorkspaceSelector.
Remove debug comment.
A null collection from the GQL query is an unexpected server response,
not a network error. Reclassify to graphql_error for accurate error
categorization.
- Wrap raw "error.something_went_wrong" string with t() in
ImportExport.vue environment export action
- Return status: "loaded" with empty data instead of "loading" for
unresolvable nodes in search tree adapter to avoid perpetual
loading spinners
Extract a safeJSONParse helper that logs and returns a fallback on
failure. Apply it to all remaining unguarded JSON.parse sites:
- Collection data parsing in _getCollectionChildren/_getRootCollections
- Request data parsing in _getCollectionChildRequests (uses flatMap to
skip unparseable requests instead of pushing null)
- Environment variables in create/duplicate/import (defaults to [])
- Environment variables in update paths (preserves existing variables
on parse failure instead of wiping to [])
- Environments view computed
- Subscription handlers (replaces earlier manual try/catch blocks)
- Remove window.testData global leak
- Replace useTimestamp live 3s polling timer with static ref
- Change implements Partial<WorkspaceProvider> to full interface
implementation with explicit throw stubs for unimplemented methods
- Remove eager TestWorkspaceProviderService instantiation from index.ts
so it's tree-shaken out of production builds
- Wrap JSON.parse in subscription handlers (request added/updated,
environment created/updated) with try/catch — a malformed server
payload would crash the handler and kill the entire subscription
stream for the session
- Clear collections and requests arrays before setting up new
subscriptions in selectWorkspace to prevent race conditions where
subscription events push into stale previous-workspace state during
the initial fetch await
getChildren threw an unhandled exception when the workspace handle was
invalid — the tree component doesn't wrap calls in try/catch, so this
propagated into Vue's render cycle. Return an empty loaded result
instead, matching the error recovery pattern used elsewhere in the
adapter.
- Reset this.subscriptions to [] after unsubscribing in selectWorkspace
to prevent unbounded array growth on workspace switches
- Narrow importRESTEnvironments filter to check E.isRight in addition
to promise fulfillment — prevents false success when all GQL calls
return E.Left
getRESTCollectionChildrenView accessed collectionHandleRef.value.data
after await boundaries — the computed ref re-evaluates on each access
and could return { type: "invalid" } if a subscription deleted the
collection during the in-flight API request, causing a TypeError on
.data access. Capture collectionID and workspaceID before the async
gap and use the stable values in .then() callbacks.
- Add type predicate to PromiseSettledResult filter in
importRESTEnvironments so TypeScript narrows to
PromiseFulfilledResult (fixes .value access on union type)
- Replace ref mutation inside computed in getRESTEnvironmentsView
with a separate computed ref — avoids Vue readonly warning and
potential infinite update loops
Handle refs from getRESTCollectionHandle/getRESTEnvironmentHandle are
lazy-computed — writing to .value is a no-op that triggers Vue readonly
warnings. The computed already auto-invalidates when the backing store
changes, so manual invalidation and name synchronization blocks are
unnecessary. Removes dead code in:
- updateRESTCollection (post-rename handle mutation)
- removeRESTCollection (post-delete handle invalidation)
- updateRESTEnvironment (post-rename handle mutation)
- removeRESTEnvironment (post-delete handle invalidation)
- platform.io doesn't exist on PlatformDef — all other callsites use
platform.kernelIO.saveFileWithDialog
- fetchAllTeams referenced runGQLQuery and GetMyTeamsDocument without
importing them, causing a build failure
- Add type: "request" to both createNewTab calls in new-collections/rest
component — required by HoppTabDocument discriminated union
- Replace non-existent HoppRESTDocument with correct types:
HoppRequestDocument in Request.vue, HoppTabDocument in helpers/tab,
remove duplicate broken import in TabHead.vue
- Tab restoration now always preserves the tab with its request data;
handle rehydration is best-effort — failed resolution no longer drops
the entire tab
- Replace delete updatedRequest.id with destructuring to avoid mutating
the caller's parameter object
- loadTabsFromPersistedState became async but callers were not updated,
causing a race where the persistence watcher could overwrite tabs with
an empty/partial array before handle resolution completes
- Replace lodash merge() with shallow spread in personal updateRESTRequest
to avoid element-by-element array merging that preserves deleted entries
- makeCollectionTree: always add each collection's own ID to the parent
set so requests on leaf collections are not silently dropped during export
- getRESTEnvironmentsView: create the environments ref once outside the
computed and update .value inside, preventing stale ref on re-evaluation
- RequestTab.vue: restore isEqualHoppRESTRequest from @hoppscotch/data
instead of lodash isEqual to preserve empty-entry filtering semantics
- removeRESTCollection and the collection-removed subscription now walk
the parent chain to remove all descendant collections and their
requests from local state (backend cascade-deletes but subscription
only fires for the top-level ID)
- RequestTab.vue sets isDirty=true when the request handle is absent
or invalid, preventing silent loss of edits
- parent?.id returns undefined for root collections; normalize to null
so sibling filter matches correctly
- sort filtered siblings with sortByOrder() before .at(-1) to ensure
generated fractional index follows the actual last sibling's order