🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
292 lines
9.7 KiB
JavaScript
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;
|
|
}
|
|
}
|