fix(canvas): per-user forgotten shape filtering + lazy loading for perf
Shapes deleted (forgotten) by a user no longer reappear on reload — forgottenBy[localDID] filtering in #applyDocToDOM and #applyPatchesToDOM means one delete = gone permanently for that user while preserving CRDT data for others. IntersectionObserver on FolkShape base class defers heavy init (API calls, iframes, feed polling) until shapes enter viewport (+500px margin), reducing initial load from 100+ concurrent requests to ~5-10 visible. Also: folk-rapp #getModulePath always uses subdomain routing (no subpath fallback), and DID re-syncs on auth-change events. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dda0492dbf
commit
98d3ce4d2f
|
|
@ -166,6 +166,7 @@ export class CommunitySync extends EventTarget {
|
|||
#syncedDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
#initialSyncFired = false;
|
||||
#wsUrl: string | null = null;
|
||||
#localDID: string = '';
|
||||
|
||||
// ── Undo/Redo state ──
|
||||
#undoStack: UndoEntry[] = [];
|
||||
|
|
@ -195,6 +196,11 @@ export class CommunitySync extends EventTarget {
|
|||
}
|
||||
}
|
||||
|
||||
/** Set the local user's DID so forgotten-shape filtering is per-user. */
|
||||
setLocalDID(did: string): void {
|
||||
this.#localDID = did;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load document and sync state from offline cache.
|
||||
* Call BEFORE connect() to show cached content immediately.
|
||||
|
|
@ -878,9 +884,14 @@ export class CommunitySync extends EventTarget {
|
|||
for (const [id, shapeData] of Object.entries(shapes)) {
|
||||
const d = shapeData as Record<string, unknown>;
|
||||
if (d.deleted === true) continue; // Deleted: not in DOM
|
||||
this.#applyShapeToDOM(shapeData);
|
||||
// If forgotten (faded), emit state-changed so canvas can apply visual
|
||||
// Skip shapes this user has forgotten — one delete = gone from their view
|
||||
const fb = d.forgottenBy;
|
||||
if (this.#localDID && fb && typeof fb === 'object'
|
||||
&& (fb as Record<string, number>)[this.#localDID]) {
|
||||
continue;
|
||||
}
|
||||
this.#applyShapeToDOM(shapeData);
|
||||
// If forgotten by others (but not this user), emit state-changed for fade visual
|
||||
if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) {
|
||||
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
||||
detail: { shapeId: id, state: 'forgotten', data: shapeData }
|
||||
|
|
@ -945,6 +956,14 @@ export class CommunitySync extends EventTarget {
|
|||
const d = shapeData as Record<string, unknown>;
|
||||
const state = this.getShapeVisualState(shapeId);
|
||||
|
||||
// Skip shapes this user has forgotten — don't create/update DOM
|
||||
const fb = d.forgottenBy;
|
||||
if (this.#localDID && fb && typeof fb === 'object'
|
||||
&& (fb as Record<string, number>)[this.#localDID]) {
|
||||
this.#removeShapeFromDOM(shapeId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state === 'deleted') {
|
||||
// Hard-deleted: remove from DOM
|
||||
this.#removeShapeFromDOM(shapeId);
|
||||
|
|
@ -952,7 +971,7 @@ export class CommunitySync extends EventTarget {
|
|||
detail: { shapeId, state: 'deleted', data: shapeData }
|
||||
}));
|
||||
} else if (state === 'forgotten') {
|
||||
// Forgotten: keep in DOM, emit state change for fade visual
|
||||
// Forgotten by others: keep in DOM, emit state change for fade visual
|
||||
this.#applyShapeToDOM(shapeData);
|
||||
this.dispatchEvent(new CustomEvent("shape-state-changed", {
|
||||
detail: { shapeId, state: 'forgotten', data: shapeData }
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ export class FolkEmbed extends FolkShape {
|
|||
|
||||
#url: string | null = null;
|
||||
#error: string | null = null;
|
||||
#embedRendered = false;
|
||||
|
||||
get url() {
|
||||
return this.#url;
|
||||
|
|
@ -320,15 +321,34 @@ export class FolkEmbed extends FolkShape {
|
|||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
// If URL is already set, render embed
|
||||
// Defer embed rendering until shape enters viewport
|
||||
if (this.#url) {
|
||||
if (this.hasBeenVisible) {
|
||||
const embedUrl = transformUrl(this.#url);
|
||||
this.#renderEmbed(content, urlInputContainer, titleText, headerTitle, this.#url, embedUrl);
|
||||
} else {
|
||||
// Show URL text as lightweight placeholder
|
||||
titleText.textContent = getDisplayTitle(this.#url);
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
protected override onBecameVisible(): void {
|
||||
if (this.#url && !this.#embedRendered) {
|
||||
const root = this.renderRoot as ShadowRoot;
|
||||
const content = root.querySelector(".content") as HTMLElement;
|
||||
const urlInputContainer = root.querySelector(".url-input-container") as HTMLElement;
|
||||
const titleText = root.querySelector(".title-text") as HTMLElement;
|
||||
const headerTitle = root.querySelector(".header-title") as HTMLElement;
|
||||
if (content && urlInputContainer && titleText && headerTitle) {
|
||||
const embedUrl = transformUrl(this.#url);
|
||||
this.#renderEmbed(content, urlInputContainer, titleText, headerTitle, this.#url, embedUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#renderEmbed(
|
||||
content: HTMLElement,
|
||||
urlInputContainer: HTMLElement,
|
||||
|
|
@ -337,6 +357,7 @@ export class FolkEmbed extends FolkShape {
|
|||
originalUrl: string,
|
||||
embedUrl: string | null
|
||||
) {
|
||||
this.#embedRendered = true;
|
||||
// Update header
|
||||
titleText.textContent = getDisplayTitle(originalUrl);
|
||||
const favicon = document.createElement("img");
|
||||
|
|
|
|||
|
|
@ -64,8 +64,7 @@ export class FolkFeed extends FolkShape {
|
|||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.#buildUI();
|
||||
this.#fetchFeed();
|
||||
this.#startAutoRefresh();
|
||||
// Defer API calls until shape is visible (onBecameVisible handles it)
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -73,6 +72,11 @@ export class FolkFeed extends FolkShape {
|
|||
this.#stopAutoRefresh();
|
||||
}
|
||||
|
||||
protected override onBecameVisible(): void {
|
||||
this.#fetchFeed();
|
||||
this.#startAutoRefresh();
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) {
|
||||
super.attributeChangedCallback(name, oldVal, newVal);
|
||||
if (["source-module", "feed-id", "feed-filter", "max-items"].includes(name)) {
|
||||
|
|
|
|||
|
|
@ -634,6 +634,7 @@ export class FolkRApp extends FolkShape {
|
|||
#refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
#modeToggleBtn: HTMLButtonElement | null = null;
|
||||
#resolvedPorts: PortDescriptor[] | null = null;
|
||||
#contentLoaded = false;
|
||||
|
||||
get moduleId() { return this.#moduleId; }
|
||||
set moduleId(value: string) {
|
||||
|
|
@ -815,9 +816,13 @@ export class FolkRApp extends FolkShape {
|
|||
// Register for enabled-state broadcasts
|
||||
FolkRApp.#instances.add(this);
|
||||
|
||||
// Load content
|
||||
// Defer heavy content loading until shape enters viewport
|
||||
if (this.#moduleId) {
|
||||
if (this.hasBeenVisible) {
|
||||
this.#renderContent();
|
||||
} else {
|
||||
this.#showLoading();
|
||||
}
|
||||
} else {
|
||||
this.#showPicker();
|
||||
}
|
||||
|
|
@ -825,8 +830,8 @@ export class FolkRApp extends FolkShape {
|
|||
return root;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback?.();
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
FolkRApp.#instances.delete(this);
|
||||
if (this.#messageHandler) {
|
||||
window.removeEventListener("message", this.#messageHandler);
|
||||
|
|
@ -838,6 +843,23 @@ export class FolkRApp extends FolkShape {
|
|||
}
|
||||
}
|
||||
|
||||
protected override onBecameVisible(): void {
|
||||
if (this.#moduleId && !this.#contentLoaded) {
|
||||
this.#renderContent();
|
||||
}
|
||||
}
|
||||
|
||||
#showLoading() {
|
||||
if (!this.#contentEl) return;
|
||||
const meta = MODULE_META[this.#moduleId];
|
||||
this.#contentEl.innerHTML = `
|
||||
<div class="rapp-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>${meta?.name || this.#moduleId}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
#buildSwitcher(switcherEl: HTMLElement) {
|
||||
const enabledSet = FolkRApp.enabledModuleIds
|
||||
?? ((window as any).__rspaceEnabledModules ? new Set((window as any).__rspaceEnabledModules as string[]) : null);
|
||||
|
|
@ -955,16 +977,9 @@ export class FolkRApp extends FolkShape {
|
|||
}
|
||||
}
|
||||
|
||||
/** Derive the base module URL path, accounting for subdomain routing */
|
||||
/** Derive the base module URL path — spaces are always subdomains */
|
||||
#getModulePath(): string {
|
||||
if (!this.#spaceSlug) {
|
||||
const pathParts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (pathParts.length >= 1) this.#spaceSlug = pathParts[0];
|
||||
}
|
||||
const space = this.#spaceSlug || "demo";
|
||||
const hostname = window.location.hostname;
|
||||
const onSubdomain = hostname.split(".").length >= 3 && hostname.startsWith(space + ".");
|
||||
return onSubdomain ? `/${this.#moduleId}` : `/${space}/${this.#moduleId}`;
|
||||
return `/${this.#moduleId}`;
|
||||
}
|
||||
|
||||
/** Check if this shape's module is currently disabled */
|
||||
|
|
@ -1014,6 +1029,8 @@ export class FolkRApp extends FolkShape {
|
|||
#renderContent() {
|
||||
if (!this.#contentEl || !this.#moduleId) return;
|
||||
|
||||
this.#contentLoaded = true;
|
||||
|
||||
// Block rendering if module is disabled
|
||||
if (this.#isModuleDisabled()) { this.#showDisabled(); return; }
|
||||
|
||||
|
|
|
|||
|
|
@ -224,6 +224,32 @@ export class FolkShape extends FolkElement {
|
|||
static tagName = "folk-shape";
|
||||
static styles = styles;
|
||||
|
||||
// ── Viewport-based lazy loading ──
|
||||
static #visibilityObserver: IntersectionObserver | null = null;
|
||||
static #ensureObserver(): IntersectionObserver {
|
||||
if (!FolkShape.#visibilityObserver) {
|
||||
FolkShape.#visibilityObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const shape = entry.target as FolkShape;
|
||||
if (entry.isIntersecting && !shape.#hasBeenVisible) {
|
||||
shape.#hasBeenVisible = true;
|
||||
shape.onBecameVisible();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: '500px' }
|
||||
);
|
||||
}
|
||||
return FolkShape.#visibilityObserver;
|
||||
}
|
||||
|
||||
#hasBeenVisible = false;
|
||||
get hasBeenVisible(): boolean { return this.#hasBeenVisible; }
|
||||
|
||||
/** Called once when shape first enters viewport (+500px margin). Override to defer heavy init. */
|
||||
protected onBecameVisible(): void {}
|
||||
|
||||
#internals = this.attachInternals();
|
||||
#attrWidth: Dimension = 0;
|
||||
#attrHeight: Dimension = 0;
|
||||
|
|
@ -466,6 +492,16 @@ export class FolkShape extends FolkElement {
|
|||
return root;
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
FolkShape.#ensureObserver().observe(this);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
FolkShape.#visibilityObserver?.unobserve(this);
|
||||
}
|
||||
|
||||
getTransformDOMRect() {
|
||||
return this.#readonlyRect;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2563,6 +2563,9 @@
|
|||
window.__communitySync?.disconnect?.();
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
// Re-sync DID for forgotten-shape filtering after auth changes
|
||||
window.__communitySync?.setLocalDID?.(getLocalDID());
|
||||
});
|
||||
|
||||
// Load module list for app switcher and tab bar + menu
|
||||
|
|
@ -3217,6 +3220,7 @@
|
|||
// Initialize offline store and CommunitySync
|
||||
const offlineStore = new OfflineStore();
|
||||
const sync = new CommunitySync(communitySlug, offlineStore);
|
||||
sync.setLocalDID(getLocalDID());
|
||||
window.__communitySync = sync;
|
||||
|
||||
// Wire history panel to CommunitySync doc
|
||||
|
|
|
|||
Loading…
Reference in New Issue