Skip to content

Commit

Permalink
Inject entire draft mod instance from Page Editor (#9298)
Browse files Browse the repository at this point in the history
  • Loading branch information
twschiller authored Oct 21, 2024
1 parent 3fed66c commit 9bfecef
Show file tree
Hide file tree
Showing 20 changed files with 585 additions and 167 deletions.
9 changes: 7 additions & 2 deletions src/contentScript/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { type getModComponentState } from "@/store/modComponents/modComponentSto
import { getPlatform } from "@/platform/platformContext";
import { StarterBrickTypes } from "@/types/starterBrickTypes";
import { modMetadataFactory } from "@/testUtils/factories/modComponentFactories";
import { RunReason } from "@/types/runtimeTypes";

let starterBrickRegistry: any;
let lifecycleModule: any;
Expand Down Expand Up @@ -172,7 +173,9 @@ describe("lifecycle", () => {
await hydrateModComponentInnerDefinitions(modComponent),
);

await lifecycleModule.runDraftModComponent(modComponent.id, starterBrick);
await lifecycleModule.runDraftModComponent(modComponent.id, starterBrick, {
runReason: RunReason.PAGE_EDITOR_RUN,
});

expect(lifecycleModule.getRunningStarterBricks()).toEqual([starterBrick]);
expect(
Expand Down Expand Up @@ -217,7 +220,9 @@ describe("lifecycle", () => {
await hydrateModComponentInnerDefinitions(modComponent),
);

await lifecycleModule.runDraftModComponent(modComponent.id, starterBrick);
await lifecycleModule.runDraftModComponent(modComponent.id, starterBrick, {
runReason: RunReason.PAGE_EDITOR_RUN,
});

// Still only a single starter brick
expect(lifecycleModule.getRunningStarterBricks()).toEqual([starterBrick]);
Expand Down
12 changes: 6 additions & 6 deletions src/contentScript/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export function TEST_getDraftModComponentStarterBrickMap(): Map<
}

/**
* Remove a mod component on the page if a activated mod component (i.e. in modComponentSlice).
* Remove a mod component on the page if an activated mod component (i.e. in modComponentSlice).
*
* @see removeDraftModComponents
*/
Expand All @@ -286,10 +286,10 @@ export function removeActivatedModComponent(modComponentId: UUID): void {
/**
* Remove draft mod components(s) from the frame.
*
* NOTE: if the draft mod component was taking the place of a activated mod component, call `reloadFrame` or a similar
* method for the mod component to be reloaded.
* NOTE: if the draft mod component was taking the place of an activated mod component, call `reloadFrameMods` or a
* similar method for the mod component to be reloaded.
*
* NOTE: this works by removing all mod components attached to the starter brick. Call `reloadFrame` or a similar
* NOTE: this works by removing all mod components attached to the starter brick. Call `reloadFrameMods` or a similar
* method to re-install/run the activated mod components.
*
* @param modComponentId an optional draft mod component id, or undefined to remove all draft mod components
Expand Down Expand Up @@ -367,6 +367,7 @@ function notifyNavigationListeners(): void {
export async function runDraftModComponent(
modComponentId: UUID,
starterBrick: StarterBrick,
{ runReason }: { runReason: RunReason },
): Promise<void> {
// Uninstall the activated mod component instance in favor of the draft mod component
if (_activatedModComponentStarterBrickMap.has(modComponentId)) {
Expand All @@ -385,8 +386,7 @@ export async function runDraftModComponent(
_draftModComponentStarterBrickMap.set(modComponentId, starterBrick);

await runStarterBrick(starterBrick, {
// The Page Editor is the only caller for runDynamic
reason: RunReason.PAGE_EDITOR,
reason: runReason,
modComponentIds: [modComponentId],
abortSignal: navigationListeners.signal,
});
Expand Down
40 changes: 26 additions & 14 deletions src/contentScript/pageEditor/draft/updateDraftModComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,22 +30,34 @@ import {
} from "@/contentScript/sidebarController";
import { isLoadedInIframe } from "@/utils/iframeUtils";
import { StarterBrickTypes } from "@/types/starterBrickTypes";
import type { RunReason } from "@/types/runtimeTypes";

export async function updateDraftModComponent({
type,
starterBrickDefinition,
modComponent,
}: DraftModComponent): Promise<void> {
// Iframes should not attempt to control top-level frame page elements, i.e., the sidebar or quick bar
// https://github.com/pixiebrix/pixiebrix-extension/pull/8226
const TopFrameStarterBricks = [
StarterBrickTypes.SIDEBAR_PANEL,
StarterBrickTypes.QUICK_BAR_ACTION,
StarterBrickTypes.DYNAMIC_QUICK_BAR,
];

export async function updateDraftModComponent(
{ type, starterBrickDefinition, modComponent }: DraftModComponent,
{
isSelectedInEditor,
runReason,
}: {
isSelectedInEditor: boolean;
runReason: RunReason;
},
): Promise<void> {
expectContext("contentScript");

// Iframes should not attempt to control the sidebar
// https://github.com/pixiebrix/pixiebrix-extension/pull/8226
if (isLoadedInIframe() && type === StarterBrickTypes.SIDEBAR_PANEL) {
if (isLoadedInIframe() && !TopFrameStarterBricks.includes(type)) {
return;
}

// HACK: adjust behavior when using the Page Editor
if (type === StarterBrickTypes.TRIGGER) {
// HACK: adjust behavior when editing the mod component using the Page Editor
if (type === StarterBrickTypes.TRIGGER && isSelectedInEditor) {
// Prevent auto-run of interval trigger when using the Page Editor because you lose track of trace across runs
const triggerDefinition =
starterBrickDefinition.definition as TriggerDefinition;
Expand All @@ -58,18 +70,18 @@ export async function updateDraftModComponent({
const starterBrick = starterBrickFactory(starterBrickDefinition);

// Don't clear actionPanel because it causes flicking between the tabs in the sidebar. The updated draft mod component
// will automatically replace the old panel because the panels are keyed by extension id
// will automatically replace the old panel because the panels are keyed by mod component id
if (type !== StarterBrickTypes.SIDEBAR_PANEL) {
removeDraftModComponents(modComponent.id, { clearTrace: false });
}

// In practice, should be a no-op because the Page Editor handles the extensionPoint
// In practice, should be a no-op because the Page Editor handles hydrating the starterBrick
const resolved = await hydrateModComponentInnerDefinitions(modComponent);

starterBrick.registerModComponent(resolved);
await runDraftModComponent(modComponent.id, starterBrick);
await runDraftModComponent(modComponent.id, starterBrick, { runReason });

if (type === StarterBrickTypes.SIDEBAR_PANEL) {
if (type === StarterBrickTypes.SIDEBAR_PANEL && isSelectedInEditor) {
await showSidebar();
await activateModComponentPanel(modComponent.id);
}
Expand Down
4 changes: 4 additions & 0 deletions src/pageEditor/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import { SimpleEventTarget } from "@/utils/SimpleEventTarget";

type NavigationDetails = WebNavigation.OnHistoryStateUpdatedDetailsType;

/**
* Navigation event fired when the inspected tab navigates, or the extension gains access to the tab.
* @see browser.webNavigation
*/
export const navigationEvent = new SimpleEventTarget<NavigationDetails>();

export function updatePageEditor() {
Expand Down
5 changes: 5 additions & 0 deletions src/pageEditor/hooks/useAddNewModComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { useInsertPane } from "@/pageEditor/panes/insert/InsertPane";
import { type ModMetadata } from "@/types/modComponentTypes";
import { createNewUnsavedModMetadata } from "@/utils/modUtils";
import { selectActivatedModMetadatas } from "@/pageEditor/store/editor/editorSelectors";
import { RunReason } from "@/types/runtimeTypes";

export type AddNewModComponent = (
adapter: ModComponentFormStateAdapter,
Expand Down Expand Up @@ -131,6 +132,10 @@ function useAddNewModComponent(modMetadata?: ModMetadata): AddNewModComponent {
updateDraftModComponent(
allFramesInInspectedTab,
adapter.asDraftModComponent(initialFormState),
{
isSelectedInEditor: true,
runReason: RunReason.PAGE_EDITOR_REGISTER,
},
);

if (adapter.starterBrickType === StarterBrickTypes.SIDEBAR_PANEL) {
Expand Down
222 changes: 222 additions & 0 deletions src/pageEditor/hooks/useRegisterDraftModInstanceOnAllFrames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/*
* Copyright (C) 2024 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { useCallback, useEffect } from "react";
import {
formStateToDraftModComponent,
modComponentToFormState,
selectType,
} from "@/pageEditor/starterBricks/adapter";
import {
removeActivatedModComponent,
updateDraftModComponent,
} from "@/contentScript/messenger/api";
import { allFramesInInspectedTab } from "@/pageEditor/context/connection";
import { navigationEvent } from "@/pageEditor/events";
import { useSelector } from "react-redux";
import {
selectActiveModComponentFormState,
selectCurrentModId,
} from "@/pageEditor/store/editor/editorSelectors";
import { StarterBrickTypes } from "@/types/starterBrickTypes";
import { selectGetCleanComponentsAndDirtyFormStatesForMod } from "@/pageEditor/store/editor/selectGetCleanComponentsAndDirtyFormStatesForMod";
import { selectModInstanceMap } from "@/store/modComponents/modInstanceSelectors";
import type { ModInstance } from "@/types/modInstanceTypes";
import { mapModInstanceToActivatedModComponents } from "@/store/modComponents/modInstanceUtils";
import useAsyncEffect from "use-async-effect";
import { assertNotNullish } from "@/utils/nullishUtils";
import { RunReason } from "@/types/runtimeTypes";
import type { UUID } from "@/types/stringTypes";
import hash from "object-hash";
import { usePreviousValue } from "@/hooks/usePreviousValue";
import type { ModComponentFormState } from "@/pageEditor/starterBricks/formStateTypes";

/**
* Map from draft mod component UUID to object-hash of updated draft. Used to avoid unnecessary re-injection.
*/
const draftModComponentStateHash = new Map<UUID, string>();

/**
* Helper to remove persisted mod instance from all frames on the tab. Prevents duplicate starter bricks when draft
* mod components are added to the page.
*/
async function removeActivatedModInstanceFromTab(
modInstance: ModInstance,
): Promise<void> {
await Promise.all(
mapModInstanceToActivatedModComponents(modInstance).map(
async (modComponent) => {
const starterBrickType = await selectType(modComponent);

if (
// The starter brick duplication issue doesn't apply to certain starter bricks:
// See https://github.com/pixiebrix/pixiebrix-extension/pull/5047
// The sidebar handles replacing the panel: https://github.com/pixiebrix/pixiebrix-extension/pull/6372
starterBrickType === StarterBrickTypes.SIDEBAR_PANEL
) {
return;
}

removeActivatedModComponent(allFramesInInspectedTab, modComponent.id);
},
),
);
}

function useOnSelectModComponent(
callback: (formState: ModComponentFormState) => void,
): void {
const activeModComponentFormState = useSelector(
selectActiveModComponentFormState,
);

// Watching for value seems a bit hacky (e.g. as opposed to watching for editorSlice actions that could impact the
// activeModComponentFormState). But it's a simple method that does not require maintaining the list of actions that
// *might* impact the selected mod component form state.
const previousActiveModComponentFormState = usePreviousValue(
activeModComponentFormState,
);

if (
activeModComponentFormState?.uuid &&
activeModComponentFormState.uuid !==
previousActiveModComponentFormState?.uuid
) {
callback(activeModComponentFormState);
}
}

/**
* Hook to register/inject selected mod draft to the current page, and re-register on top-level frame navigation.
*
* Mod components within the selected mod draft are registered:
* 1. On initial mount, to ensure the draft mod instance is always present
* 2. On select, to prevent interval triggers from running when selected
* 3. On page navigation, to ensure the draft mod instance is always present
* 4. When a non-selected mod component is updated. E.g., mod option values are updated. (Updating the selected mod
* component is updated by ReloadToolbar.)
*
* @see ReloadToolbar
* @see RunReason.PAGE_EDITOR_REGISTER
* @since 2.1.6
*/
function useRegisterDraftModInstanceOnAllFrames(): void {
const modId = useSelector(selectCurrentModId);
assertNotNullish(modId, "modId is required");

const modInstanceMap = useSelector(selectModInstanceMap);
const activeModComponentFormState = useSelector(
selectActiveModComponentFormState,
);
const getEditorInstance = useSelector(
selectGetCleanComponentsAndDirtyFormStatesForMod,
);

const activatedModInstance = modInstanceMap.get(modId);
const editorInstance = getEditorInstance(modId);

useAsyncEffect(async () => {
if (activatedModInstance) {
// Remove non-draft mod instance from the page. removeActivatedModInstanceFromTab is safe to call multiple times
// per mod instance (it's a NOP if the mod instance is registered in a frame).
await removeActivatedModInstanceFromTab(activatedModInstance);
}
}, [activatedModInstance]);

// Replace with the draft mod instance. Updated when the selected mod component changes, because auto-run behavior
// differs based on whether a mod component is selected or not.
const updateDraftModInstance = useCallback(async () => {
// NOTE: logic accounts for activated mod components that have been deleted. But it does not account for
// unsaved draft mod component that have been deleted since the last injection. The draft mod component
// deletion code is currently responsible for removing those from the tab

const { cleanModComponents, dirtyModComponentFormStates } = editorInstance;

const draftFormStates = [
...(await Promise.all(
cleanModComponents.map(async (x) => modComponentToFormState(x)),
)),
...dirtyModComponentFormStates,
];

for (const draftFormState of draftFormStates) {
const isSelectedInEditor =
activeModComponentFormState?.uuid === draftFormState.uuid;

// ReloadToolbar will handle running the selected draft mod component
if (!isSelectedInEditor) {
const draftModComponent = formStateToDraftModComponent(draftFormState);

// PERFORMANCE: only re-register if the component's state has changed. It would technically be safe to
// updateDraftModComponent on every change to the mod (even for different mod components), but computing the
// hash is cheaper. An additional benefit of skipping re-register is that interval triggers won't
// have their interval reset.
const stateHash = hash({
draftModComponent,
isSelectedInEditor,
});

if (draftModComponentStateHash.get(draftFormState.uuid) !== stateHash) {
updateDraftModComponent(allFramesInInspectedTab, draftModComponent, {
isSelectedInEditor,
runReason: RunReason.PAGE_EDITOR_REGISTER,
});
}

draftModComponentStateHash.set(draftFormState.uuid, stateHash);
}
}
}, [activeModComponentFormState, editorInstance]);

// Run updateDraftModInstance whenever the mod instance configuration changes
useAsyncEffect(async () => {
await updateDraftModInstance();
}, [updateDraftModInstance]);

// Register draft mod component on select. From there, ReloadToolbar will control re-running the mod component.
// Currently, registering on select is to stop interval triggers from running when selected.
useOnSelectModComponent(async (draftFormState) => {
const draftModComponent = formStateToDraftModComponent(draftFormState);
updateDraftModComponent(allFramesInInspectedTab, draftModComponent, {
isSelectedInEditor: true,
runReason: RunReason.PAGE_EDITOR_REGISTER,
});
});

useEffect(() => {
const callback = async () => {
if (activatedModInstance) {
// Remove activated mod instance from the page
await removeActivatedModInstanceFromTab(activatedModInstance);
}

// XXX: should the navigation handler force runReason to be PAGE_EDITOR_RUN? For SPA navigation, the normal
// page lifecycle will handle. For full navigation, there's a race between the lifecycle running the activated
// mod components and the Page Editor removing the activated mod components. Ideally, the draft mod instance
// would take precedence.
draftModComponentStateHash.clear();
await updateDraftModInstance();
};

navigationEvent.add(callback);
return () => {
navigationEvent.remove(callback);
};
}, [updateDraftModInstance, activatedModInstance]);
}

export default useRegisterDraftModInstanceOnAllFrames;
Loading

0 comments on commit 9bfecef

Please sign in to comment.