Files
rspade_system/app/RSpade/Breadcrumbs/Rsx_Breadcrumb_Resolver.js
root 29b1abc0a1 Add progressive breadcrumb resolution with caching
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-12 08:55:34 +00:00

292 lines
9.7 KiB
JavaScript

/**
* Rsx_Breadcrumb_Resolver - Progressive breadcrumb resolution with caching
*
* Provides instant breadcrumb rendering using cached data while validating
* against live action data in the background.
*
* Resolution Phases:
* 1. Immediate Cached Render - If ALL chain links cached, render instantly
* 2. Active Action Resolution - Await live action's breadcrumb methods
* 3. Chain Walk - Walk parents using cache or detached action loads
* 4. Render Chain - Emit chain structure (may have null labels)
* 5. Progressive Updates - Update labels as they resolve
*
* Usage:
* const cancel = Rsx_Breadcrumb_Resolver.stream(action, url, (data) => {
* // data.title - Page title (null if pending)
* // data.chain - Array of {url, label, is_active, resolved}
* // data.fully_resolved - True when all labels resolved
* });
*
* // Call cancel() to stop streaming (e.g., on navigation)
*/
class Rsx_Breadcrumb_Resolver {
/**
* In-memory cache: url -> { label, label_active, parent_url }
* Cleared on page reload. Invalidated by visiting pages.
*/
static _cache = {};
/**
* Generation counter for cancellation detection.
* Incremented on each stream() call and when cancelled.
*/
static _generation = 0;
/**
* Stream breadcrumb data as it becomes available
*
* @param {Spa_Action} action - The active action instance
* @param {string} url - Current URL (pathname + search, no hash)
* @param {Function} callback - Called with breadcrumb data on each update
* @returns {Function} Cancel function - call to stop streaming
*/
static stream(action, url, callback) {
const generation = ++Rsx_Breadcrumb_Resolver._generation;
// Normalize URL (strip hash if present)
const normalized_url = Rsx_Breadcrumb_Resolver._normalize_url(url);
// Start async resolution (fires callbacks as data becomes available)
Rsx_Breadcrumb_Resolver._resolve(action, normalized_url, generation, callback);
// Return cancel function
return () => {
// Only increment if this is still the active generation
if (generation === Rsx_Breadcrumb_Resolver._generation) {
Rsx_Breadcrumb_Resolver._generation++;
}
};
}
/**
* Normalize URL by stripping hash
*/
static _normalize_url(url) {
const hash_index = url.indexOf('#');
return hash_index >= 0 ? url.substring(0, hash_index) : url;
}
/**
* Check if resolution should continue (not cancelled)
*/
static _is_active(generation) {
return generation === Rsx_Breadcrumb_Resolver._generation;
}
/**
* Main resolution orchestrator
*/
static async _resolve(action, url, generation, callback) {
// Phase 1: Try immediate cached render
const cached_chain = Rsx_Breadcrumb_Resolver._try_build_cached_chain(url);
if (cached_chain) {
// Full cache hit - render immediately
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
callback({
title: null, // Title will be resolved in Phase 2
chain: cached_chain,
fully_resolved: false
});
}
// Phase 2: Await active action resolution
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
const active_data = await Rsx_Breadcrumb_Resolver._resolve_active_action(action, url);
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
// Check if parent changed from cached value (stale cache)
const cached_entry = Rsx_Breadcrumb_Resolver._cache[url];
const parent_changed = cached_chain && cached_entry &&
cached_entry.parent_url !== active_data.parent_url;
// Phase 3-5: Walk chain and render progressively
await Rsx_Breadcrumb_Resolver._walk_and_render_chain(
active_data,
url,
generation,
callback,
parent_changed || !cached_chain // Force re-render if parent changed or no cached render
);
}
/**
* Phase 1: Try to build a complete chain from cache
* Returns null if any link is missing from cache
*/
static _try_build_cached_chain(url) {
const chain = [];
let current_url = url;
while (current_url) {
const entry = Rsx_Breadcrumb_Resolver._cache[current_url];
if (!entry) {
// Cache miss - can't build complete chain
return null;
}
chain.unshift({
url: current_url,
label: current_url === url ? entry.label_active : entry.label,
is_active: current_url === url,
resolved: true
});
current_url = entry.parent_url;
}
return chain;
}
/**
* Phase 2: Resolve all breadcrumb data from the live active action
*/
static async _resolve_active_action(action, url) {
// Await all breadcrumb methods in parallel
const [title, label, label_active, parent_url] = await Promise.all([
action.page_title(),
action.breadcrumb_label(),
action.breadcrumb_label_active(),
action.breadcrumb_parent()
]);
// Update cache with fresh values
Rsx_Breadcrumb_Resolver._cache[url] = {
label: label,
label_active: label_active || label,
parent_url: parent_url || null
};
return {
title: title,
label: label,
label_active: label_active || label,
parent_url: parent_url || null
};
}
/**
* Phase 3-5: Walk parent chain and render progressively
*/
static async _walk_and_render_chain(active_data, url, generation, callback, force_render) {
// Start chain with active action
const chain = [{
url: url,
label: active_data.label_active,
is_active: true,
resolved: true
}];
// Track pending async operations for progressive updates
let pending_count = 0;
let chain_complete = false;
// Helper to emit current state
const emit = () => {
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
callback({
title: active_data.title,
chain: [...chain], // Clone to prevent mutation issues
fully_resolved: chain_complete && pending_count === 0
});
};
// Walk up parent chain
let parent_url = active_data.parent_url;
while (parent_url) {
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) return;
const cached = Rsx_Breadcrumb_Resolver._cache[parent_url];
if (cached) {
// Cache hit - use cached values immediately
chain.unshift({
url: parent_url,
label: cached.label,
is_active: false,
resolved: true
});
parent_url = cached.parent_url;
} else {
// Cache miss - need to load detached action
// Add placeholder entry
const entry_url = parent_url;
chain.unshift({
url: entry_url,
label: null, // Placeholder - will be resolved
is_active: false,
resolved: false
});
pending_count++;
// Load the detached action synchronously for chain structure
const detached = await Spa.load_detached_action(entry_url, { use_cached_data: true });
if (!Rsx_Breadcrumb_Resolver._is_active(generation)) {
if (detached) detached.stop();
return;
}
if (detached) {
// Get parent_url to continue chain walk
const next_parent = await detached.breadcrumb_parent();
// Get label while we have the action
const label = await detached.breadcrumb_label();
const label_active = await detached.breadcrumb_label_active();
// Update cache
Rsx_Breadcrumb_Resolver._cache[entry_url] = {
label: label,
label_active: label_active || label,
parent_url: next_parent || null
};
// Update chain entry with resolved label
const found_entry = chain.find(e => e.url === entry_url);
if (found_entry) {
found_entry.label = label;
found_entry.resolved = true;
pending_count--;
}
detached.stop();
parent_url = next_parent;
} else {
// Failed to load - stop chain here
parent_url = null;
}
}
}
// Chain structure complete
chain_complete = true;
// Emit final state (or intermediate if still pending)
if (force_render || pending_count === 0) {
emit();
}
}
/**
* Clear the cache (called on window reload automatically)
* Can be called manually if needed
*/
static clear_cache() {
Rsx_Breadcrumb_Resolver._cache = {};
}
/**
* Get cache entry for debugging/inspection
*/
static get_cached(url) {
return Rsx_Breadcrumb_Resolver._cache[url] || null;
}
}