Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.24% covered (warning)
88.24%
30 / 34
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sidebar_Open_Preservation
88.24% covered (warning)
88.24%
30 / 34
83.33% covered (warning)
83.33%
5 / 6
17.47
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 add_preopen_body_classes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 print_sidebar_docking_gate_script
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
8.81
 should_pre_render_docked_shell
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 should_preserve_sidebar_open_state
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Sidebar Open Preservation file.
4 *
5 * @package automattic/jetpack-agents-manager
6 */
7
8namespace Automattic\Jetpack\Agents_Manager;
9
10/**
11 * Preserves the Agents Manager sidebar-open body classes across full wp-admin
12 * navigations so the next page load can pre-apply them server-side (avoiding a
13 * flicker before the React app boots).
14 *
15 * The open state comes from Open_State_Store::get_cached(), and the pre-render only
16 * runs when the Agents Manager app is actually loading on this request — so the
17 * pre-rendered shell is always reconciled by the app that mounts to manage it,
18 * never left orphaned.
19 *
20 * The server can know the persisted "open && docked" preference, but not the
21 * live viewport width or the editor's fullscreen mode. So a tiny synchronous
22 * gate script is printed on `in_admin_header` (before the content paints) to
23 * re-evaluate the real dock gates and strip the pre-rendered classes when the
24 * chat will actually float — pre-paint, so there is no flash.
25 */
26class Sidebar_Open_Preservation {
27    /**
28     * Class instance.
29     *
30     * @var Sidebar_Open_Preservation
31     */
32    private static $instance;
33
34    /**
35     * Body class marking the docked sidebar shell.
36     *
37     * @var string
38     */
39    private const SIDEBAR_CONTAINER_CLASS = 'agents-manager-sidebar-container';
40
41    /**
42     * Body class marking the sidebar as open.
43     *
44     * @var string
45     */
46    private const SIDEBAR_OPEN_CLASS = 'agents-manager-sidebar-container--sidebar-open';
47
48    /**
49     * Creates instance.
50     *
51     * @return void
52     */
53    public static function init() {
54        if ( self::$instance === null ) {
55            self::$instance = new self();
56        }
57    }
58
59    /**
60     * Sidebar_Open_Preservation constructor.
61     */
62    public function __construct() {
63        // Run last so our class sits at the end of the `admin_body_class` list. Otherwise
64        // a later filter could append its class without a leading space and glue it onto
65        // ours, breaking the CSS selector that pre-opens the sidebar.
66        add_filter( 'admin_body_class', array( $this, 'add_preopen_body_classes' ), PHP_INT_MAX );
67
68        // Reconcile the pre-rendered shell against the live dock gates before
69        // the page content paints.
70        add_action( 'in_admin_header', array( $this, 'print_sidebar_docking_gate_script' ) );
71    }
72
73    /**
74     * Inject pre-open assistant classes in initial admin body markup.
75     *
76     * @param string $classes Existing admin body classes.
77     * @return string
78     */
79    public function add_preopen_body_classes( string $classes ): string {
80        if ( ! $this->should_pre_render_docked_shell() ) {
81            return $classes;
82        }
83
84        $body_classes_with_sidebar_classes = implode(
85            ' ',
86            array_filter(
87                array(
88                    $classes,
89                    self::SIDEBAR_CONTAINER_CLASS,
90                    self::SIDEBAR_OPEN_CLASS,
91                )
92            )
93        );
94
95        return ' ' . $body_classes_with_sidebar_classes . ' ';
96    }
97
98    /**
99     * Print the synchronous sidebar-docking reconciliation script.
100     *
101     * Only emitted when the docked shell was pre-rendered. Because we optimistically
102     * inject the docked sidebar body classes, this script reconciles the gates that
103     * the React hook applies and removes those classes so the chat floats instead.
104     *
105     * The script lives in src/js/sidebar-docking-gate.ts and is inlined (not
106     * referenced via `src`) on purpose: it must run render-blocking before paint,
107     * and a same- or cross-origin fetch would add latency to that blocking window.
108     * Reading the bundled file and printing it inline keeps it a real, lintable JS
109     * file with zero request cost.
110     *
111     * @return void
112     */
113    public function print_sidebar_docking_gate_script() {
114        if ( ! $this->should_pre_render_docked_shell() ) {
115            return;
116        }
117
118        global $wp_filesystem;
119
120        if ( empty( $wp_filesystem ) ) {
121            require_once ABSPATH . 'wp-admin/includes/file.php';
122            WP_Filesystem();
123        }
124
125        $script_path = __DIR__ . '/../build/sidebar-docking-gate.js';
126
127        if ( empty( $wp_filesystem ) || ! $wp_filesystem->exists( $script_path ) ) {
128            return;
129        }
130
131        $script = $wp_filesystem->get_contents( $script_path );
132        if ( ! is_string( $script ) || '' === $script ) {
133            return;
134        }
135
136        wp_print_inline_script_tag( $script );
137    }
138
139    /**
140     * Whether the docked-open shell should be pre-rendered on this request.
141     *
142     * True only when the app is loading (so it will take over the shell once
143     * mounted) and the cached state is both open and docked — the only state
144     * that reshapes the admin layout. A cold session (no cache), a closed
145     * sidebar, or a floating (undocked) chat all pre-render nothing.
146     *
147     * @return bool
148     */
149    private function should_pre_render_docked_shell(): bool {
150        if ( ! $this->should_preserve_sidebar_open_state() ) {
151            return false;
152        }
153
154        $state = Open_State_Store::get_cached();
155
156        return $state && true === $state['agents_manager_open'] && true === $state['agents_manager_docked'];
157    }
158
159    /**
160     * Whether sidebar open preservation should run for this request.
161     *
162     * Gated on the same decision that loads the app (its active variant), so the
163     * pre-rendered shell only appears where the app will mount to reconcile it.
164     *
165     * @return bool
166     */
167    private function should_preserve_sidebar_open_state(): bool {
168        return null !== Agents_Manager::get_active_variant();
169    }
170}